ouija 0.1.0-alpha.211

Cross-machine AI session daemon — bridges Claude Code sessions via tmux injection and Nostr P2P
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
pub mod claude_code;
pub mod codex;
pub mod opencode;

use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};

/// Default hard timeout for a backend availability probe (`cli --version`).
const AVAILABILITY_TIMEOUT: Duration = Duration::from_secs(3);

/// Run `command` to completion, killing it if it outlives `timeout`.
///
/// Returns `Some(status)` if the process exited on its own within the deadline,
/// or `None` if it could not be spawned or was killed for exceeding `timeout`.
///
/// Some backend CLIs are npx/npm wrappers whose `--version` can hang while a
/// wrapper resolves packages online. A blocking `Command::output()` would then
/// stall daemon startup and every session-start registration (which probes
/// availability per backend). Bounding the wait keeps those paths responsive.
fn run_with_timeout(command: &mut Command, timeout: Duration) -> Option<std::process::ExitStatus> {
    let mut child = command.spawn().ok()?;
    let deadline = Instant::now() + timeout;
    loop {
        match child.try_wait() {
            Ok(Some(status)) => return Some(status),
            Ok(None) => {
                if Instant::now() >= deadline {
                    let _ = child.kill();
                    let _ = child.wait();
                    return None;
                }
                std::thread::sleep(Duration::from_millis(25));
            }
            Err(_) => {
                let _ = child.kill();
                let _ = child.wait();
                return None;
            }
        }
    }
}

/// Whether `cli_name --version` exits successfully within `AVAILABILITY_TIMEOUT`.
///
/// Shared by every backend's default `is_available`. Output is discarded; only
/// the exit status within the timeout matters.
fn cli_reports_version(cli_name: &str) -> bool {
    let mut command = Command::new(cli_name);
    command
        .arg("--version")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    matches!(run_with_timeout(&mut command, AVAILABILITY_TIMEOUT), Some(status) if status.success())
}

/// Pre-trust mise config files in `dir` so spawned shells don't block on an
/// interactive "Trust them? [Yes/No/All]" prompt.
///
/// When a shell with `mise activate` sees an untrusted mise config, it prompts
/// for trust before loading shims. In HttpApi-backed sessions that prompt
/// blocks `opencode attach` forever — no `.opencode` descendant ever appears
/// in the pane tree, and the reaper's `pane_alive` check reaps the session
/// at the 60s grace boundary. Trusting the config non-interactively at spawn
/// time eliminates the stall.
///
/// Best-effort: no-op when mise isn't installed, when the dir has no mise
/// config, or when `mise trust` fails for any reason.
pub fn pre_trust_mise(dir: &str) {
    if cfg!(test) {
        return;
    }
    const CONFIGS: &[&str] = &[
        "mise.toml",
        ".mise.toml",
        "mise/config.toml",
        ".tool-versions",
    ];
    for name in CONFIGS {
        let path = format!("{dir}/{name}");
        if !std::path::Path::new(&path).exists() {
            continue;
        }
        let _ = std::process::Command::new("mise")
            .args(["trust", &path])
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status();
    }
}

/// Registry of available coding assistant backends.
///
/// Holds all known backends and provides lookup by name plus a configurable
/// default. Global operations (e.g. scanning for any assistant process) use
/// `all_process_names()`, while per-session operations resolve the backend
/// via `get(name)`.
#[derive(Debug)]
pub struct BackendRegistry {
    backends: Vec<Arc<dyn CodingAssistant>>,
    default_name: String,
}

impl BackendRegistry {
    pub fn new(backends: Vec<Arc<dyn CodingAssistant>>, default: &str) -> Self {
        Self {
            backends,
            default_name: default.to_string(),
        }
    }

    pub fn default_registry() -> Self {
        Self::new(
            vec![
                Arc::new(claude_code::ClaudeCode) as _,
                Arc::new(opencode::OpenCode) as _,
                Arc::new(codex::Codex) as _,
            ],
            "claude-code",
        )
    }

    pub fn get(&self, name: &str) -> Option<Arc<dyn CodingAssistant>> {
        self.backends.iter().find(|b| b.name() == name).cloned()
    }

    pub fn default(&self) -> Arc<dyn CodingAssistant> {
        self.get(&self.default_name)
            .expect("default backend must exist")
    }

    /// Returns names of backends whose binary is found in PATH.
    pub fn available(&self) -> Vec<&str> {
        self.backends
            .iter()
            .filter(|b| b.is_available())
            .map(|b| b.name())
            .collect()
    }

    pub fn all_process_names(&self) -> Vec<String> {
        self.backends
            .iter()
            .flat_map(|b| b.process_names().iter().map(|s| s.to_string()))
            .collect()
    }

    /// Every registered backend paired with its process names, regardless of
    /// availability.
    ///
    /// Process-tree detection (`detect_backend_in_pane`) matches a running pane's
    /// process names against this set. It must NOT be filtered by `available()`:
    /// that runs each backend's `is_available()` CLI probe (e.g. a slow npx
    /// `codex --version`), which both blocks the caller and would drop a live
    /// pane whenever its backend CLI is slow to answer.
    pub fn all_backend_process_names(&self) -> Vec<(String, Vec<String>)> {
        self.backends
            .iter()
            .map(|b| {
                (
                    b.name().to_string(),
                    b.process_names().iter().map(|s| s.to_string()).collect(),
                )
            })
            .collect()
    }

    /// Whether `backend_name` delivers messages over HTTP rather than the tmux TUI.
    ///
    /// HTTP-delivered sessions (e.g. opencode on a shared serve) reach the
    /// backend through its API independently of the tmux pane, so pane-process
    /// liveness is not a death signal for them: the attach TUI can die — or
    /// never start, on version skew — while the session stays fully reachable.
    /// Returns `false` for unknown backend names.
    pub fn uses_http_delivery(&self, backend_name: &str) -> bool {
        self.get(backend_name)
            .is_some_and(|b| matches!(b.delivery_mode(), DeliveryMode::HttpApi { .. }))
    }
}

/// How a backend receives messages from ouija.
#[derive(Debug, Clone)]
pub enum DeliveryMode {
    /// Messages delivered via tmux paste-buffer injection into a TUI process.
    TuiInjection,
    /// Messages delivered via HTTP API to a headless server process.
    HttpApi {
        #[allow(dead_code)]
        serve_command: String,
        #[allow(dead_code)]
        attach_command: String,
    },
}

#[derive(Debug)]
pub struct StartOpts {
    pub project_dir: String,
    pub worktree: Option<WorktreeMode>,
    /// LLM model override (passed through to backend CLI / API).
    pub model: Option<String>,
    /// Reasoning effort / variant (passed through to backend CLI / API).
    pub effort: Option<String>,
    /// Claude Code permission mode override. Other backends ignore this.
    pub permission_mode: Option<String>,
}

#[derive(Debug)]
pub struct ResumeOpts {
    pub project_dir: String,
    pub session_id: Option<String>,
    pub worktree: Option<WorktreeMode>,
    /// LLM model override (passed through to backend CLI / API).
    pub model: Option<String>,
    /// Reasoning effort / variant (passed through to backend CLI / API).
    pub effort: Option<String>,
    /// Claude Code permission mode override. Other backends ignore this.
    pub permission_mode: Option<String>,
}

#[derive(Debug, Clone)]
pub enum WorktreeMode {
    Named(String),
    Disposable,
}

#[derive(Debug, Clone, Copy)]
pub struct InjectConfig {
    pub paste_settle_ms: u64,
    pub use_inner_bracketed_paste: bool,
    pub startup_inject_delay_secs: u64,
}

/// A terminal-based coding assistant that ouija can orchestrate.
#[allow(dead_code)]
pub trait CodingAssistant: Send + Sync + std::fmt::Debug + 'static {
    fn name(&self) -> &str;
    fn cli_name(&self) -> &str;
    fn process_names(&self) -> &[&str];
    fn delivery_mode(&self) -> DeliveryMode;
    fn build_start_command(&self, opts: &StartOpts) -> String;
    fn build_resume_command(&self, opts: &ResumeOpts) -> Option<String>;
    fn detect_session_id(&self, project_dir: &str) -> Option<String>;
    fn tui_ready_pattern(&self) -> Option<&str>;
    fn inject_config(&self) -> InjectConfig;
    fn config_dir_name(&self) -> &str;
    fn resolve_project_root<'a>(&self, path: &'a str) -> &'a str {
        path
    }
    fn has_project_history(&self, dir: &Path) -> bool;
    fn compact_command(&self) -> Option<&str> {
        None
    }
    fn exit_command(&self) -> Option<&str>;
    fn install(&self) -> anyhow::Result<()>;
    fn is_available(&self) -> bool {
        cli_reports_version(self.cli_name())
    }
    fn description_file_priority(&self) -> &[&str] {
        &["README.md"]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn registry_available_returns_backends_with_binaries() {
        let registry = BackendRegistry::default_registry();
        let available = registry.available();
        assert!(available.iter().all(|name| !name.is_empty()));
    }

    #[test]
    fn run_with_timeout_returns_status_for_fast_success() {
        let status = run_with_timeout(&mut Command::new("true"), Duration::from_secs(3));
        assert!(status.is_some_and(|s| s.success()));
    }

    #[test]
    fn run_with_timeout_returns_status_for_fast_failure() {
        let status = run_with_timeout(&mut Command::new("false"), Duration::from_secs(3));
        assert!(status.is_some_and(|s| !s.success()));
    }

    #[test]
    fn run_with_timeout_kills_and_returns_none_when_deadline_exceeded() {
        // `sleep 5` would never finish inside a 200ms budget. The helper must
        // give up promptly rather than block — this is the guarantee that keeps
        // a hanging `codex --version` wrapper from stalling daemon startup.
        let start = Instant::now();
        let status = run_with_timeout(
            Command::new("sleep").arg("5"),
            Duration::from_millis(200),
        );
        assert!(status.is_none(), "timed-out process must return None");
        assert!(
            start.elapsed() < Duration::from_secs(2),
            "helper must return near the deadline, not wait for the process"
        );
    }

    #[test]
    fn run_with_timeout_returns_none_for_missing_binary() {
        let status = run_with_timeout(
            &mut Command::new("ouija-nonexistent-binary-xyz"),
            Duration::from_secs(3),
        );
        assert!(status.is_none());
    }

    #[test]
    fn cli_reports_version_true_for_command_that_exits_zero() {
        // `true` ignores `--version` and exits 0, so the probe reports available.
        assert!(cli_reports_version("true"));
    }

    #[test]
    fn cli_reports_version_false_for_missing_binary() {
        assert!(!cli_reports_version("ouija-nonexistent-binary-xyz"));
    }

    #[test]
    fn uses_http_delivery_distinguishes_backends() {
        let registry = BackendRegistry::default_registry();
        // opencode runs on a shared serve and is reached over HTTP.
        assert!(registry.uses_http_delivery("opencode"));
        // claude-code is driven through the tmux TUI.
        assert!(!registry.uses_http_delivery("claude-code"));
        // codex-cli is driven through the tmux TUI, not HTTP.
        assert!(!registry.uses_http_delivery("codex-cli"));
        // Unknown backends default to false.
        assert!(!registry.uses_http_delivery("nonexistent"));
    }

    #[test]
    fn registry_includes_codex_backend() {
        let registry = BackendRegistry::default_registry();
        let codex = registry
            .get("codex-cli")
            .expect("codex-cli backend must be registered");
        assert_eq!(codex.cli_name(), "codex");
        // Its process name participates in the global process-name sweep.
        assert!(
            registry
                .all_process_names()
                .iter()
                .any(|n| n == "codex")
        );
    }

    /// A backend whose CLI binary does not exist, so `is_available()` is false.
    /// Used to prove process-tree detection does not gate on availability.
    #[derive(Debug)]
    struct UnavailableBackend;
    impl CodingAssistant for UnavailableBackend {
        fn name(&self) -> &str {
            "ghost"
        }
        fn cli_name(&self) -> &str {
            "ouija-nonexistent-binary-xyz"
        }
        fn process_names(&self) -> &[&str] {
            &["ghostproc"]
        }
        fn delivery_mode(&self) -> DeliveryMode {
            DeliveryMode::TuiInjection
        }
        fn build_start_command(&self, _: &StartOpts) -> String {
            String::new()
        }
        fn build_resume_command(&self, _: &ResumeOpts) -> Option<String> {
            None
        }
        fn detect_session_id(&self, _: &str) -> Option<String> {
            None
        }
        fn tui_ready_pattern(&self) -> Option<&str> {
            None
        }
        fn inject_config(&self) -> InjectConfig {
            InjectConfig {
                paste_settle_ms: 0,
                use_inner_bracketed_paste: false,
                startup_inject_delay_secs: 0,
            }
        }
        fn config_dir_name(&self) -> &str {
            ".ghost"
        }
        fn has_project_history(&self, _: &Path) -> bool {
            false
        }
        fn exit_command(&self) -> Option<&str> {
            None
        }
        fn install(&self) -> anyhow::Result<()> {
            Ok(())
        }
    }

    #[test]
    fn all_backend_process_names_ignores_availability() {
        let registry = BackendRegistry::new(vec![Arc::new(UnavailableBackend) as _], "ghost");
        // The CLI binary is absent, so availability-based listing excludes it.
        assert!(registry.available().is_empty());
        // But process-tree detection must still know its process names, so a
        // live pane running that backend is never dropped just because its CLI
        // is slow or absent when asked for --version (the codex npx-wrapper bug).
        let names = registry.all_backend_process_names();
        assert_eq!(names.len(), 1);
        assert_eq!(names[0].0, "ghost");
        assert_eq!(names[0].1, vec!["ghostproc".to_string()]);
    }

    #[test]
    fn all_backend_process_names_covers_every_default_backend() {
        let registry = BackendRegistry::default_registry();
        let names = registry.all_backend_process_names();
        for backend in ["claude-code", "opencode", "codex-cli"] {
            assert!(
                names.iter().any(|(n, _)| n == backend),
                "{backend} missing from detection candidate set: {names:?}"
            );
        }
    }
}