slancha-wire 0.5.19

Magic-wormhole for AI agents — bilateral signed-message bus over a mailbox relay
Documentation
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
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! Background-process bootstrapper for the MCP path.
//!
//! Post-pair, an agent shouldn't have to ask the user "start the daemon?" —
//! `wire_pair_confirm` invokes [`ensure_daemon_running`] + [`ensure_notify_running`]
//! so push/pull and OS toasts are already armed by the time the agent surfaces
//! "paired ✓" back to chat.
//!
//! ## Idempotency
//!
//! Each subcommand writes its pid record to `$WIRE_HOME/state/wire/<name>.pid`
//! on spawn. The next call reads the record and skips spawning if the pid is
//! still alive. Stale pid files (process died) are silently overwritten.
//!
//! ## Pid-file shape (P0.4, 0.5.11)
//!
//! The pid file used to be a raw integer (`12345\n`). Today's debug surfaced
//! a process running an OLD binary text in memory under a current symlink,
//! and `wire status` had no way to detect that. The pid file is now a
//! versioned JSON record:
//!
//! ```json
//! {
//!   "schema": "wire-daemon-pid-v1",
//!   "pid": 12345,
//!   "bin_path": "/usr/local/bin/wire",
//!   "version": "0.5.11",
//!   "started_at": "2026-05-16T01:23:45Z",
//!   "did": "did:wire:paul-mac",
//!   "relay_url": "https://wireup.net"
//! }
//! ```
//!
//! Readers are TOLERANT of the legacy int form for one transition cycle —
//! `read_daemon_pid` falls through to raw-int parse when JSON decode fails
//! and reports `version: None` so callers can degrade gracefully.
//!
//! ## Wait-until-alive
//!
//! On spawn, we wait briefly for the child to be alive before persisting the
//! pid file. A concurrent CLI seeing the file pointing at a not-yet-bound
//! PID is the "daemon reports running but can't accept connections" race
//! spark flagged in our P0.4 design call.
//!
//! ## Detachment (Unix)
//!
//! Spawned with stdio nulled. Since `wire mcp` runs without a controlling
//! TTY (it's a stdio MCP server, not a login shell), the spawned children
//! inherit no TTY → no SIGHUP arrives when the parent exits, so they
//! survive a Claude Code restart cycle. PIDs are reaped by init.
//!
//! Worst case: a child dies; the next `wire_pair_confirm` call respawns it.
//! No data is lost (outbox/inbox is on disk, content-addressed dedupe).

use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Schema string written into every JSON pid file. Bumped if the pid-file
/// shape ever changes incompatibly. Readers warn on unknown schema.
pub const DAEMON_PID_SCHEMA: &str = "wire-daemon-pid-v1";

/// Versioned daemon pid record — the JSON form written by 0.5.11+.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DaemonPid {
    /// Schema discriminator. Always `wire-daemon-pid-v1` for now.
    pub schema: String,
    pub pid: u32,
    /// Absolute path of the binary that was exec'd. Catches today's exact
    /// bug: a stale 0.2.4 daemon process kept running under a symlink that
    /// was repointed at 0.5.10 — `wire --version` says 0.5.10 but the
    /// running daemon's text in memory is still 0.2.4.
    pub bin_path: String,
    /// CARGO_PKG_VERSION captured at spawn. Compared against the CLI's
    /// own version on every invocation; mismatch = loud warn.
    pub version: String,
    /// RFC3339 timestamp of spawn.
    pub started_at: String,
    /// Self DID — catches multi-identity contamination (one user, two wire
    /// identities on same host, daemon launched as wrong one). Cheap
    /// field, expensive bug.
    pub did: Option<String>,
    /// Relay this daemon was bound to at spawn. Catches daemon-bound-to-
    /// old-relay-after-migration drift.
    pub relay_url: Option<String>,
}

/// Result of reading a pid file. Distinguishes legacy-int (no metadata)
/// from JSON (full metadata) so callers can degrade gracefully.
#[derive(Debug, Clone)]
pub enum PidRecord {
    Json(DaemonPid),
    LegacyInt(u32),
    Missing,
    Corrupt(String),
}

impl PidRecord {
    pub fn pid(&self) -> Option<u32> {
        match self {
            PidRecord::Json(d) => Some(d.pid),
            PidRecord::LegacyInt(p) => Some(*p),
            _ => None,
        }
    }
}

/// Ensure a `wire daemon --interval 5` process is alive. Returns `Ok(true)`
/// if a fresh process was spawned, `Ok(false)` if one was already running.
pub fn ensure_daemon_running() -> Result<bool> {
    ensure_background("daemon", &["daemon", "--interval", "5"])
}

/// Ensure a `wire notify --interval 2` process is alive (OS toasts on
/// every new verified inbox event). Returns true if newly spawned.
pub fn ensure_notify_running() -> Result<bool> {
    ensure_background("notify", &["notify", "--interval", "2"])
}

fn pid_file(name: &str) -> Result<PathBuf> {
    Ok(crate::config::state_dir()?.join(format!("{name}.pid")))
}

/// Snapshot of daemon liveness state read through ONE consistent
/// view. Consumed by `wire status`, `wire doctor`'s `daemon` check,
/// and `daemon_pid_consistency` so all three surfaces agree by
/// construction — issue #2 root cause was three call sites that
/// each computed liveness independently and disagreed for 25 min.
#[derive(Debug, Clone)]
pub struct DaemonLiveness {
    /// PID claimed by `daemon.pid` (None if missing/corrupt).
    pub pidfile_pid: Option<u32>,
    /// True iff `pidfile_pid` is currently a live process.
    pub pidfile_alive: bool,
    /// Every PID matching `pgrep -f "wire daemon"`. Empty if pgrep is
    /// unavailable (non-Unix systems, missing util) — the consumer
    /// must not treat empty as "no daemons" without considering this.
    pub pgrep_pids: Vec<u32>,
    /// PIDs in `pgrep_pids` that do NOT match `pidfile_pid`. These are
    /// orphan daemons racing the cursor with the pidfile-recorded one.
    pub orphan_pids: Vec<u32>,
    /// Full parsed pidfile record (Json / LegacyInt / Missing / Corrupt).
    pub record: PidRecord,
}

/// True iff `pid` is currently a live OS process. Linux: `/proc/<pid>`.
/// Other Unix: `kill -0`. Returns false on any error.
pub fn pid_is_alive(pid: u32) -> bool {
    #[cfg(target_os = "linux")]
    {
        std::path::Path::new(&format!("/proc/{pid}")).exists()
    }
    #[cfg(not(target_os = "linux"))]
    {
        std::process::Command::new("kill")
            .args(["-0", &pid.to_string()])
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
    }
}

/// Read the daemon pid file + pgrep in one shot, producing a snapshot
/// every caller can interpret identically. The point of this helper
/// is that three independent callers used to compute liveness three
/// different ways (#2): pidfile-pid-alive (cmd_status), pgrep-only
/// (early check_daemon_health), neither (check_daemon_pid_consistency).
/// Now all three flow through the same `DaemonLiveness`.
pub fn daemon_liveness() -> DaemonLiveness {
    let record = read_pid_record("daemon");
    let pidfile_pid = record.pid();
    let pidfile_alive = pidfile_pid.map(pid_is_alive).unwrap_or(false);
    let pgrep_pids: Vec<u32> = std::process::Command::new("pgrep")
        .args(["-f", "wire daemon"])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| {
            String::from_utf8_lossy(&o.stdout)
                .split_whitespace()
                .filter_map(|s| s.parse::<u32>().ok())
                .collect()
        })
        .unwrap_or_default();
    let orphan_pids: Vec<u32> = pgrep_pids
        .iter()
        .filter(|p| Some(**p) != pidfile_pid)
        .copied()
        .collect();
    DaemonLiveness {
        pidfile_pid,
        pidfile_alive,
        pgrep_pids,
        orphan_pids,
        record,
    }
}

/// Read a pid file, tolerating both JSON and legacy-int forms. Never
/// panics — corrupt input becomes `PidRecord::Corrupt`.
pub fn read_pid_record(name: &str) -> PidRecord {
    let path = match pid_file(name) {
        Ok(p) => p,
        Err(_) => return PidRecord::Missing,
    };
    let body = match std::fs::read_to_string(&path) {
        Ok(b) => b,
        Err(_) => return PidRecord::Missing,
    };
    let trimmed = body.trim();
    if trimmed.is_empty() {
        return PidRecord::Missing;
    }
    // JSON form first.
    if trimmed.starts_with('{') {
        match serde_json::from_str::<DaemonPid>(trimmed) {
            Ok(d) => return PidRecord::Json(d),
            Err(e) => return PidRecord::Corrupt(format!("JSON parse: {e}")),
        }
    }
    // Legacy raw-int form — keep readable for one transition cycle so a
    // 0.5.11 daemon can take over from a 0.5.10 leftover without
    // operator intervention.
    match trimmed.parse::<u32>() {
        Ok(pid) => PidRecord::LegacyInt(pid),
        Err(e) => PidRecord::Corrupt(format!("expected int or JSON: {e}")),
    }
}

/// Write a JSON pid record. P0.4: replaces the raw-int write.
fn write_pid_record(name: &str, record: &DaemonPid) -> Result<()> {
    let path = pid_file(name)?;
    let body = serde_json::to_vec_pretty(record)?;
    std::fs::write(&path, body)?;
    Ok(())
}

/// Build a `DaemonPid` for a freshly-spawned child. Reads bin_path,
/// current binary version, identity DID, and bound relay URL.
fn build_pid_record(pid: u32) -> DaemonPid {
    let bin_path = std::env::current_exe()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_default();
    let version = env!("CARGO_PKG_VERSION").to_string();
    let started_at = time::OffsetDateTime::now_utc()
        .format(&time::format_description::well_known::Rfc3339)
        .unwrap_or_default();
    let (did, relay_url) = identity_for_pid_record();
    DaemonPid {
        schema: DAEMON_PID_SCHEMA.to_string(),
        pid,
        bin_path,
        version,
        started_at,
        did,
        relay_url,
    }
}

/// Best-effort: pull DID + relay_url from the configured identity. None
/// fields are written as `null` so the file stays well-formed even before
/// the operator runs `wire init`.
fn identity_for_pid_record() -> (Option<String>, Option<String>) {
    let did = crate::config::read_agent_card()
        .ok()
        .and_then(|card| {
            card.get("did")
                .and_then(Value::as_str)
                .map(str::to_string)
        });
    let relay_url = crate::config::read_relay_state()
        .ok()
        .and_then(|state| {
            state
                .get("self")
                .and_then(|s| s.get("relay_url"))
                .and_then(Value::as_str)
                .map(str::to_string)
        });
    (did, relay_url)
}

/// Wait briefly for `process_alive(pid)` to be true. Returns true if the
/// child went live within the budget. Default budget is 500ms — enough for
/// std::process::Command::spawn to fork + exec on any reasonable platform.
fn wait_until_alive(pid: u32, budget: Duration) -> bool {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if process_alive(pid) {
            return true;
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    process_alive(pid)
}

fn ensure_background(name: &str, args: &[&str]) -> Result<bool> {
    // Test escape hatch — tests/mcp_pair.rs spawns wire mcp with this env
    // var set so wire_pair_confirm doesn't fork persistent daemon/notify
    // processes that survive the test's temp WIRE_HOME.
    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
        return Ok(false);
    }

    // Skip spawn if existing pid is still alive.
    if let Some(pid) = read_pid_record(name).pid()
        && process_alive(pid)
    {
        return Ok(false);
    }

    crate::config::ensure_dirs()?;
    let exe = std::env::current_exe()?;
    let child = Command::new(&exe)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()?;

    // P0.4: wait until the child is actually alive before persisting the
    // pid file. Otherwise a concurrent CLI sees the file pointing at a
    // PID that isn't yet bound to anything — "daemon reports running but
    // can't accept connections" race.
    let pid = child.id();
    if !wait_until_alive(pid, Duration::from_millis(500)) {
        anyhow::bail!(
            "spawned `wire {}` (pid {pid}) did not appear alive within 500ms",
            args.join(" ")
        );
    }

    let record = build_pid_record(pid);
    write_pid_record(name, &record)?;
    Ok(true)
}

/// Check the running daemon's version against the CLI's CARGO_PKG_VERSION.
/// Returns Some(stale_version) if they disagree, None if they match (or no
/// daemon, or legacy-int pidfile without version info).
///
/// Called by `wire status` + `wire doctor`. The intent is loud, non-fatal
/// warning — don't BLOCK CLI invocations on version mismatch (operator may
/// be running a one-shot debug while daemon is old), but DO make it
/// impossible to miss.
pub fn daemon_version_mismatch() -> Option<String> {
    let record = read_pid_record("daemon");
    let pid = record.pid()?;
    if !process_alive(pid) {
        return None;
    }
    match record {
        PidRecord::Json(d) => {
            if d.version != env!("CARGO_PKG_VERSION") {
                Some(d.version)
            } else {
                None
            }
        }
        PidRecord::LegacyInt(_) => {
            // Legacy pidfile = pre-0.5.11 daemon writing raw int. By
            // definition older than this CLI, so flag it.
            Some("<pre-0.5.11>".to_string())
        }
        _ => None,
    }
}

#[cfg(target_os = "linux")]
fn process_alive(pid: u32) -> bool {
    std::path::Path::new(&format!("/proc/{pid}")).exists()
}

#[cfg(not(target_os = "linux"))]
fn process_alive(pid: u32) -> bool {
    // macOS / others: signal-0 check via `kill -0 <pid>` exit status.
    Command::new("kill")
        .args(["-0", &pid.to_string()])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

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

    #[test]
    fn process_alive_self() {
        assert!(process_alive(std::process::id()));
    }

    #[test]
    fn process_alive_zero_is_false_or_self() {
        assert!(!process_alive(99_999_999));
    }

    #[test]
    fn pid_record_round_trips_via_json_form() {
        // P0.4 contract: a record written by 0.5.11 must be readable by
        // 0.5.11. If serde gets out of sync with the file format, every
        // single CLI invocation breaks silently.
        crate::config::test_support::with_temp_home(|| {
            crate::config::ensure_dirs().unwrap();
            let record = DaemonPid {
                schema: DAEMON_PID_SCHEMA.to_string(),
                pid: 12345,
                bin_path: "/usr/local/bin/wire".to_string(),
                version: "0.5.11".to_string(),
                started_at: "2026-05-16T01:23:45Z".to_string(),
                did: Some("did:wire:paul-mac".to_string()),
                relay_url: Some("https://wireup.net".to_string()),
            };
            write_pid_record("daemon", &record).unwrap();
            let read = read_pid_record("daemon");
            match read {
                PidRecord::Json(d) => assert_eq!(d, record),
                other => panic!("expected JSON record, got {other:?}"),
            }
        });
    }

    #[test]
    fn pid_record_tolerates_legacy_int_form() {
        // The whole point of LegacyInt: a 0.5.11 daemon must be able to
        // take over from a 0.5.10 leftover without operator intervention.
        // If this assertion fails, every operator with a 0.5.10 daemon
        // running has to manually delete their pidfile on upgrade.
        crate::config::test_support::with_temp_home(|| {
            crate::config::ensure_dirs().unwrap();
            let path = super::pid_file("daemon").unwrap();
            std::fs::write(&path, "98765").unwrap();
            let read = read_pid_record("daemon");
            match read {
                PidRecord::LegacyInt(pid) => assert_eq!(pid, 98765),
                other => panic!("expected LegacyInt, got {other:?}"),
            }
        });
    }

    #[test]
    fn pid_record_corrupt_reports_corrupt_not_panic() {
        // Today's debug had a stale pidfile pointing at a dead PID. The
        // reader was tolerant. A future bug might write garbage; the reader
        // must not panic — it must report Corrupt so wire doctor can
        // surface it visibly.
        crate::config::test_support::with_temp_home(|| {
            crate::config::ensure_dirs().unwrap();
            let path = super::pid_file("daemon").unwrap();
            std::fs::write(&path, "not-a-pid-or-json {{{").unwrap();
            let read = read_pid_record("daemon");
            assert!(matches!(read, PidRecord::Corrupt(_)), "got {read:?}");
        });
    }

    #[test]
    fn daemon_version_mismatch_returns_none_when_no_pidfile() {
        crate::config::test_support::with_temp_home(|| {
            assert_eq!(daemon_version_mismatch(), None);
        });
    }
}