Skip to main content

wire/cli/
session.rs

1use anyhow::{Context, Result, anyhow, bail};
2use serde_json::{Value, json};
3
4fn resolve_session_name(name: Option<&str>) -> Result<String> {
5    if let Some(n) = name {
6        return Ok(crate::session::sanitize_name(n));
7    }
8    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9    let registry = crate::session::read_registry().unwrap_or_default();
10    Ok(crate::session::derive_name_from_cwd(&cwd, &registry))
11}
12
13#[allow(clippy::too_many_arguments)] // 11 transport-mix flags; the v0.8 audit
14// (.planning/research/codebase-audit-2026-05-23.md) recommends a config-struct
15// refactor for v0.8. For v0.7.0 we ship the flag-explosion as-is.
16pub(super) fn cmd_session_new(
17    name_arg: Option<&str>,
18    relay: &str,
19    with_local: bool,
20    local_relay: &str,
21    with_lan: bool,
22    lan_relay: Option<&str>,
23    with_uds: bool,
24    uds_socket: Option<&std::path::Path>,
25    no_daemon: bool,
26    local_only: bool,
27    as_json: bool,
28) -> Result<()> {
29    // v0.6.6: --local-only implies --with-local (a federation-free
30    // session with no endpoints at all would be unaddressable).
31    let with_local = with_local || local_only;
32    // v0.7.0-alpha.9: --with-lan requires --lan-relay <url>.
33    if with_lan && lan_relay.is_none() {
34        bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
35    }
36    // v0.7.0-alpha.18: --with-uds requires --uds-socket <path>.
37    if with_uds && uds_socket.is_none() {
38        bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
39    }
40    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
41    let mut registry = crate::session::read_registry().unwrap_or_default();
42    let name = match name_arg {
43        Some(n) => crate::session::sanitize_name(n),
44        None => crate::session::derive_name_from_cwd(&cwd, &registry),
45    };
46    let session_home = crate::session::session_dir(&name)?;
47
48    let already_exists = session_home.exists()
49        && session_home
50            .join("config")
51            .join("wire")
52            .join("agent-card.json")
53            .exists();
54    if already_exists {
55        // Idempotent: re-register the cwd (if not already), refresh the
56        // daemon if requested, surface the env-var line. Do not re-init
57        // identity — that would clobber the keypair.
58        registry
59            .by_cwd
60            .insert(cwd.to_string_lossy().into_owned(), name.clone());
61        crate::session::write_registry(&registry)?;
62        let info = render_session_info(&name, &session_home, &cwd)?;
63        emit_session_new_result(&info, "already_exists", as_json)?;
64        if !no_daemon {
65            ensure_session_daemon(&session_home)?;
66        }
67        return Ok(());
68    }
69
70    std::fs::create_dir_all(&session_home)
71        .with_context(|| format!("creating session dir {session_home:?}"))?;
72
73    // Phase 1: init identity in the new session's WIRE_HOME. For
74    // federation-bound sessions we pass `--relay` so init also
75    // allocates a federation slot in the same step; for `--local-only`
76    // we run init with `--offline` (v0.9 requires explicit reachability
77    // acknowledgement at init time) because cmd_session_new allocates
78    // the local-relay slot itself via try_allocate_local_slot below.
79    // The session is not actually slotless — init is just deferred to
80    // the subsequent allocation pass.
81    let init_args: Vec<&str> = if local_only {
82        vec!["init", "--offline"]
83    } else {
84        vec!["init", "--relay", relay]
85    };
86    let init_status = super::run_wire_with_home(&session_home, &init_args)?;
87    if !init_status.success() {
88        let how = if local_only {
89            format!("`wire init {name}` (local-only)")
90        } else {
91            format!("`wire init {name} --relay {relay}`")
92        };
93        bail!("{how} failed inside session dir {session_home:?}");
94    }
95
96    // Phase 2: claim the handle on the federation relay — SKIPPED when
97    // `--local-only`. Local-only sessions have no public address and
98    // accept reserved nicks (e.g. cwd-derived `wire`) because nothing
99    // tries to publish them.
100    let effective_handle = if local_only {
101        name.clone()
102    } else {
103        let mut claim_attempt = 0u32;
104        let mut effective = name.clone();
105        loop {
106            claim_attempt += 1;
107            let status =
108                super::run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
109            if status.success() {
110                break;
111            }
112            if claim_attempt >= 5 {
113                bail!(
114                    "5 failed attempts to claim a handle on {relay} for session {name}. \
115                     Try `wire session destroy {name} --force` and re-run with a different name, \
116                     or use `--local-only` if you don't need a federation address."
117                );
118            }
119            let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
120            let suffix = crate::session::derive_name_from_cwd(&attempt_path, &registry);
121            let token = suffix
122                .rsplit('-')
123                .next()
124                .filter(|t| t.len() == 4)
125                .map(str::to_string)
126                .unwrap_or_else(|| format!("{claim_attempt}"));
127            effective = format!("{name}-{token}");
128        }
129        effective
130    };
131
132    // Persist the cwd → name mapping NOW so subsequent invocations from
133    // this directory short-circuit to the "already_exists" branch.
134    registry
135        .by_cwd
136        .insert(cwd.to_string_lossy().into_owned(), name.clone());
137    crate::session::write_registry(&registry)?;
138
139    // v0.5.17: --with-local probes the local relay and, if it's
140    // reachable, allocates a second slot there. The session's
141    // relay_state.json grows a `self.endpoints[]` array carrying both
142    // endpoints; routing layer (cmd_push) prefers local for sister-
143    // session peers that also have a local slot.
144    //
145    // v0.6.6 (--local-only): try_allocate_local_slot is the ONLY slot
146    // allocation; a failed probe leaves the session with no endpoints,
147    // which we surface as a hard error (the operator asked for local-
148    // only but the local relay isn't running — fix that first).
149    if with_local {
150        try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
151        if local_only {
152            // Verify the local slot landed. If the local relay was
153            // unreachable, the session would be unreachable from
154            // anywhere — surface that loudly instead of leaving an
155            // orphaned session dir.
156            let relay_state_path = session_home.join("config").join("wire").join("relay.json");
157            let state: Value = std::fs::read(&relay_state_path)
158                .ok()
159                .and_then(|b| serde_json::from_slice(&b).ok())
160                .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
161            let endpoints = crate::endpoints::self_endpoints(&state);
162            let has_local = endpoints
163                .iter()
164                .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
165            if !has_local {
166                bail!(
167                    "--local-only requested but local-relay probe at {local_relay} failed — \
168                     ensure the local relay is running (`wire service install --local-relay`), \
169                     then re-run `wire session new {name} --local-only`."
170                );
171            }
172        }
173    }
174
175    // v0.7.0-alpha.9: also allocate a LAN-bound slot if requested.
176    // Sits AFTER local because cmd_session_new's flow is "add endpoints
177    // alongside existing self.endpoints[]" — order independent post-init.
178    if with_lan && let Some(lan_url) = lan_relay {
179        try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
180    }
181    // v0.7.0-alpha.18: also allocate a UDS slot if requested.
182    if with_uds && let Some(socket_path) = uds_socket {
183        try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
184    }
185
186    if !no_daemon {
187        ensure_session_daemon(&session_home)?;
188    }
189
190    let info = render_session_info(&name, &session_home, &cwd)?;
191    emit_session_new_result(&info, "created", as_json)
192}
193
194/// Coerce a JSON document whose root is valid JSON but not an object
195/// (`[]`, `"x"`, `42`, `null`) back to `{}` so callers can mutate it
196/// with `as_object_mut()` without panicking. The slot-allocation paths
197/// load `relay.json` with a parse-failure fallback to `{}`, but a file
198/// holding valid non-object JSON sailed past that fallback and hit the
199/// `expect("relay_state root is an object")` below.
200fn coerce_object_root(v: &mut serde_json::Value) {
201    if !v.is_object() {
202        *v = serde_json::json!({});
203    }
204}
205
206/// v0.7.0-alpha.18: probe + allocate against a UDS-bound relay, then
207/// merge the resulting Uds endpoint into `self.endpoints[]` so paired
208/// sister sessions can route over the local socket instead of loopback
209/// HTTP. Uses the hand-rolled `uds_request` HTTP/1.1 client from
210/// alpha.17 — reqwest has no UDS support.
211///
212/// Non-fatal on probe/alloc failure (mirrors try_allocate_local_slot
213/// and try_allocate_lan_slot semantics): session stays at existing
214/// endpoint mix, operator can retry once the UDS relay is up.
215#[cfg(unix)]
216fn try_allocate_uds_slot(
217    session_home: &std::path::Path,
218    handle: &str,
219    uds_socket: &std::path::Path,
220) {
221    // Probe healthz first so we fail fast with a clear stderr if the
222    // socket doesn't exist OR isn't a wire relay.
223    let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
224        Ok((200, _)) => true,
225        Ok((status, body)) => {
226            eprintln!(
227                "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
228                String::from_utf8_lossy(&body)
229            );
230            return;
231        }
232        Err(e) => {
233            eprintln!(
234                "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
235                 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
236            );
237            return;
238        }
239    };
240    if !healthz {
241        return;
242    }
243
244    // Allocate a slot via the same hand-rolled HTTP/1.1 client.
245    let alloc_body = serde_json::json!({"handle": handle}).to_string();
246    let (status, body) = match crate::relay_client::uds_request(
247        uds_socket,
248        "POST",
249        "/v1/slot/allocate",
250        &[("Content-Type", "application/json")],
251        alloc_body.as_bytes(),
252    ) {
253        Ok(r) => r,
254        Err(e) => {
255            eprintln!(
256                "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
257            );
258            return;
259        }
260    };
261    if status >= 300 {
262        eprintln!(
263            "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
264            String::from_utf8_lossy(&body)
265        );
266        return;
267    }
268    let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
269        Ok(a) => a,
270        Err(e) => {
271            eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
272            return;
273        }
274    };
275
276    let state_path = session_home.join("config").join("wire").join("relay.json");
277    let mut state: serde_json::Value = std::fs::read(&state_path)
278        .ok()
279        .and_then(|b| serde_json::from_slice(&b).ok())
280        .unwrap_or_else(|| serde_json::json!({}));
281
282    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
283        .get("self")
284        .and_then(|s| s.get("endpoints"))
285        .and_then(|e| e.as_array())
286        .map(|arr| {
287            arr.iter()
288                .filter_map(|v| {
289                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
290                })
291                .collect()
292        })
293        .unwrap_or_default();
294    endpoints.push(crate::endpoints::Endpoint::uds(
295        format!("unix://{}", uds_socket.display()),
296        alloc.slot_id.clone(),
297        alloc.slot_token.clone(),
298    ));
299
300    coerce_object_root(&mut state);
301    let self_obj = state
302        .as_object_mut()
303        .expect("relay_state root coerced to object above")
304        .entry("self")
305        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
306    if !self_obj.is_object() {
307        *self_obj = serde_json::Value::Object(serde_json::Map::new());
308    }
309    if let Some(obj) = self_obj.as_object_mut() {
310        obj.insert(
311            "endpoints".into(),
312            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
313        );
314    }
315    if let Err(e) = std::fs::write(
316        &state_path,
317        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
318    ) {
319        eprintln!("wire session new: failed to write {state_path:?}: {e}");
320        return;
321    }
322    eprintln!(
323        "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
324        uds_socket.display(),
325        alloc.slot_id
326    );
327}
328
329#[cfg(not(unix))]
330fn try_allocate_uds_slot(
331    _session_home: &std::path::Path,
332    _handle: &str,
333    _uds_socket: &std::path::Path,
334) {
335    eprintln!(
336        "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
337    );
338}
339
340/// v0.7.0-alpha.9: probe + allocate against a LAN-bound relay, then
341/// merge the resulting Lan endpoint into `self.endpoints[]` so peers
342/// pulling the agent-card see a third reachable address.
343///
344/// Mirrors `try_allocate_local_slot` but tags the endpoint
345/// `EndpointScope::Lan`. Non-fatal: if probe or alloc fails, the
346/// session stays at whatever endpoint mix it already had — operators
347/// can retry with `wire session new --with-lan --lan-relay <url>` once
348/// the LAN relay is up.
349fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
350    let probe = match crate::relay_client::build_blocking_client(Some(
351        std::time::Duration::from_millis(500),
352    )) {
353        Ok(c) => c,
354        Err(e) => {
355            eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
356            return;
357        }
358    };
359    let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
360    match probe.get(&healthz_url).send() {
361        Ok(resp) if resp.status().is_success() => {}
362        Ok(resp) => {
363            eprintln!(
364                "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
365                resp.status()
366            );
367            return;
368        }
369        Err(e) => {
370            eprintln!(
371                "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
372                 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
373                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
374            );
375            return;
376        }
377    };
378
379    let lan_client = crate::relay_client::RelayClient::new(lan_relay);
380    let alloc = match lan_client.allocate_slot(Some(handle)) {
381        Ok(a) => a,
382        Err(e) => {
383            eprintln!(
384                "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
385            );
386            return;
387        }
388    };
389
390    let state_path = session_home.join("config").join("wire").join("relay.json");
391    let mut state: serde_json::Value = std::fs::read(&state_path)
392        .ok()
393        .and_then(|b| serde_json::from_slice(&b).ok())
394        .unwrap_or_else(|| serde_json::json!({}));
395
396    // Read existing endpoints array and add the LAN one. Preserve
397    // federation / local entries already there.
398    let mut endpoints: Vec<crate::endpoints::Endpoint> = state
399        .get("self")
400        .and_then(|s| s.get("endpoints"))
401        .and_then(|e| e.as_array())
402        .map(|arr| {
403            arr.iter()
404                .filter_map(|v| {
405                    serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
406                })
407                .collect()
408        })
409        .unwrap_or_default();
410    endpoints.push(crate::endpoints::Endpoint::lan(
411        lan_relay.trim_end_matches('/').to_string(),
412        alloc.slot_id.clone(),
413        alloc.slot_token.clone(),
414    ));
415
416    coerce_object_root(&mut state);
417    let self_obj = state
418        .as_object_mut()
419        .expect("relay_state root coerced to object above")
420        .entry("self")
421        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
422    if !self_obj.is_object() {
423        *self_obj = serde_json::Value::Object(serde_json::Map::new());
424    }
425    if let Some(obj) = self_obj.as_object_mut() {
426        obj.insert(
427            "endpoints".into(),
428            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
429        );
430    }
431    if let Err(e) = std::fs::write(
432        &state_path,
433        serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
434    ) {
435        eprintln!("wire session new: failed to write {state_path:?}: {e}");
436        return;
437    }
438    eprintln!(
439        "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
440        alloc.slot_id
441    );
442}
443
444/// v0.5.17: probe the named local relay; if `/healthz` returns ok within
445/// a short timeout, allocate a slot there and update the session's
446/// `relay_state.json` `self.endpoints[]` to advertise both endpoints.
447///
448/// Failure to reach the local relay is NOT fatal — the session stays
449/// federation-only. Logs to stderr on failure so operators can tell
450/// the local relay isn't running, but doesn't abort the bootstrap.
451fn try_allocate_local_slot(
452    session_home: &std::path::Path,
453    handle: &str,
454    _federation_relay: &str,
455    local_relay: &str,
456) {
457    // Probe healthz with a tight timeout. Use a fresh client (don't
458    // share the daemon-wide one) so the timeout is local to this call.
459    let probe = match crate::relay_client::build_blocking_client(Some(
460        std::time::Duration::from_millis(500),
461    )) {
462        Ok(c) => c,
463        Err(e) => {
464            eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
465            return;
466        }
467    };
468    let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
469    match probe.get(&healthz_url).send() {
470        Ok(resp) if resp.status().is_success() => {}
471        Ok(resp) => {
472            eprintln!(
473                "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
474                resp.status()
475            );
476            return;
477        }
478        Err(e) => {
479            eprintln!(
480                "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
481                 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
482                crate::relay_client::format_transport_error(&anyhow::Error::new(e))
483            );
484            return;
485        }
486    };
487
488    // Allocate a slot on the local relay.
489    let local_client = crate::relay_client::RelayClient::new(local_relay);
490    let alloc = match local_client.allocate_slot(Some(handle)) {
491        Ok(a) => a,
492        Err(e) => {
493            eprintln!(
494                "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
495            );
496            return;
497        }
498    };
499
500    // Merge into the session's relay.json. We invoke wire via
501    // run_wire_with_home for federation calls (subprocess isolation),
502    // but relay.json is a simple file we can edit directly
503    // — and need to, because there's no `wire bind-relay --add-local`
504    // command yet (could add later; out of scope for v0.5.17 MVP).
505    //
506    // v0.5.20 BUG FIX: previously joined `relay-state.json` here, which
507    // does not exist (canonical filename is `relay.json` per
508    // `config::relay_state_path`). The mis-named file write succeeded
509    // but landed in a sibling path nothing else reads. Every
510    // `wire session new --with-local` invocation silently degraded to
511    // federation-only despite the "local slot allocated" stderr line.
512    // Caught by deploying v0.5.19 on the dev laptop and inspecting the
513    // session's relay.json — it had only the federation endpoint.
514    let state_path = session_home.join("config").join("wire").join("relay.json");
515    let mut state: serde_json::Value = std::fs::read(&state_path)
516        .ok()
517        .and_then(|b| serde_json::from_slice(&b).ok())
518        .unwrap_or_else(|| serde_json::json!({}));
519    // Read the existing federation self info (already written by
520    // `wire init` + `wire bind-relay` path during session bootstrap).
521    let fed_endpoint = state.get("self").and_then(|s| {
522        let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
523        let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
524        let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
525        Some(crate::endpoints::Endpoint::federation(
526            url.to_string(),
527            slot_id.to_string(),
528            slot_token.to_string(),
529        ))
530    });
531
532    let local_endpoint = crate::endpoints::Endpoint::local(
533        local_relay.trim_end_matches('/').to_string(),
534        alloc.slot_id.clone(),
535        alloc.slot_token.clone(),
536    );
537
538    let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
539    if let Some(f) = fed_endpoint.clone() {
540        endpoints.push(f);
541    }
542    endpoints.push(local_endpoint);
543
544    // v0.6.6: when there's no federation endpoint (e.g. `--local-only`
545    // bootstrap), the legacy top-level `relay_url` / `slot_id` /
546    // `slot_token` fields must point at the LOCAL endpoint so callers
547    // that read those legacy fields (send_pair_drop_ack, post-v0.6.6
548    // ensure_self_with_relay fallback, v0.5.16-era back-compat readers)
549    // still find a valid slot. Pre-v0.6.6 this branch wrote
550    // `relay_url: federation_relay` with no slot_id, which produced
551    // half-populated self state that broke wire-accept on local-only
552    // sessions.
553    let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
554        Some(f) => (f.relay_url, f.slot_id, f.slot_token),
555        None => (
556            local_relay.trim_end_matches('/').to_string(),
557            alloc.slot_id.clone(),
558            alloc.slot_token.clone(),
559        ),
560    };
561    coerce_object_root(&mut state);
562    let self_obj = state
563        .as_object_mut()
564        .expect("relay_state root coerced to object above")
565        .entry("self")
566        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
567    // The entry might be Value::Null (left by read_relay_state's default
568    // template) — replace with an object before mutating.
569    if !self_obj.is_object() {
570        *self_obj = serde_json::Value::Object(serde_json::Map::new());
571    }
572    if let Some(obj) = self_obj.as_object_mut() {
573        obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
574        obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
575        obj.insert(
576            "slot_token".into(),
577            serde_json::Value::String(legacy_slot_token),
578        );
579        obj.insert(
580            "endpoints".into(),
581            serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
582        );
583    }
584
585    if let Err(e) = std::fs::write(
586        &state_path,
587        serde_json::to_vec_pretty(&state).unwrap_or_default(),
588    ) {
589        eprintln!(
590            "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
591        );
592        return;
593    }
594    eprintln!(
595        "wire session new: local slot allocated on {local_relay} (slot_id={})",
596        alloc.slot_id
597    );
598}
599
600fn render_session_info(
601    name: &str,
602    session_home: &std::path::Path,
603    cwd: &std::path::Path,
604) -> Result<serde_json::Value> {
605    let card_path = session_home
606        .join("config")
607        .join("wire")
608        .join("agent-card.json");
609    let (did, handle) = if card_path.exists() {
610        let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
611        let did = card
612            .get("did")
613            .and_then(Value::as_str)
614            .unwrap_or("")
615            .to_string();
616        let handle = card
617            .get("handle")
618            .and_then(Value::as_str)
619            .map(str::to_string)
620            .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
621        (did, handle)
622    } else {
623        (String::new(), String::new())
624    };
625    Ok(json!({
626        "name": name,
627        "home_dir": session_home.to_string_lossy(),
628        "cwd": cwd.to_string_lossy(),
629        "did": did,
630        "handle": handle,
631        "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
632    }))
633}
634
635fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
636    if as_json {
637        let mut obj = info.clone();
638        obj["status"] = json!(status);
639        println!("{}", serde_json::to_string(&obj)?);
640    } else {
641        let name = info["name"].as_str().unwrap_or("?");
642        let handle = info["handle"].as_str().unwrap_or("?");
643        let home = info["home_dir"].as_str().unwrap_or("?");
644        let did = info["did"].as_str().unwrap_or("?");
645        let export = info["export"].as_str().unwrap_or("?");
646        let prefix = if status == "already_exists" {
647            "session already exists (re-registered cwd)"
648        } else {
649            "session created"
650        };
651        println!(
652            "{prefix}\n  name:   {name}\n  handle: {handle}\n  did:    {did}\n  home:   {home}\n\nactivate with:\n  {export}"
653        );
654    }
655    Ok(())
656}
657
658/// v0.7.0-alpha.2: idempotent per-cwd session creation.
659///
660/// When the auto-detect (`maybe_adopt_session_wire_home`) finds no
661/// registered session for the current cwd — including via parent-walk —
662/// this creates one inline so every Claude tab in a fresh project gets
663/// its own wire identity rather than collapsing onto the machine-wide
664/// default. Without this, multiple Claudes in unwired cwds all render
665/// the same character (the default identity's character), defeating the
666/// "every session looks different" promise.
667///
668/// Opt-out: `WIRE_AUTO_INIT=0` env var (e.g. set in shell profile or
669/// `run_wire_with_home` subprocess context).
670///
671/// Best-effort: any failure (no home dir, name collision pathology,
672/// `wire init` subprocess crash) is logged to stderr and we fall back
673/// to default identity. Must not block MCP startup.
674///
675/// MUST be called BEFORE worker thread spawn (env::set_var safety).
676pub fn maybe_auto_init_cwd_session(label: &str) {
677    if std::env::var("WIRE_HOME").is_ok() {
678        return; // explicit override OR auto-detect already won
679    }
680    if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
681        return; // operator opt-out
682    }
683    let cwd = match std::env::current_dir() {
684        Ok(c) => c,
685        Err(_) => return,
686    };
687    // Defensive: parent-walk re-check (maybe_adopt_session_wire_home
688    // already runs but we want to be robust to ordering).
689    if crate::session::detect_session_wire_home(&cwd).is_some() {
690        return;
691    }
692
693    // v0.7.0-alpha.12 (review-fix #135): SINGLE global auto-init lock
694    // (was per-name in alpha.3, briefly per-cwd in alpha.12-iter1).
695    // Two different cwds with the same basename (e.g. /a/projx +
696    // /b/projx) used to race outside the lock: both read empty
697    // registry, both derived name="projx", per-name lock didn't help
698    // because they queued on DIFFERENT locks (cwd-A and cwd-B).
699    //
700    // Single lock serializes ALL auto-init across the sessions_root.
701    // Inside the lock: re-read registry, derive_name_from_cwd which
702    // adds path-hash suffix when basename is occupied by another cwd
703    // already committed to the registry. Different cwds get DIFFERENT
704    // names guaranteed.
705    //
706    // Cost: parallel auto-inits in different cwds now serialize
707    // (~hundreds of ms each when local relay is up). Acceptable —
708    // auto-init runs once per cwd per machine; not a hot path.
709    use fs2::FileExt;
710    let sessions_root = match crate::session::sessions_root() {
711        Ok(r) => r,
712        Err(_) => return,
713    };
714    if let Err(e) = std::fs::create_dir_all(&sessions_root) {
715        eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
716        return;
717    }
718    let lock_path = sessions_root.join(".auto-init.lock");
719    let lock_file = match std::fs::OpenOptions::new()
720        .create(true)
721        .truncate(false)
722        .read(true)
723        .write(true)
724        .open(&lock_path)
725    {
726        Ok(f) => f,
727        Err(e) => {
728            eprintln!(
729                "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
730            );
731            return;
732        }
733    };
734    if let Err(e) = lock_file.lock_exclusive() {
735        eprintln!(
736            "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
737        );
738        return;
739    }
740    // Lock acquired. Read registry + derive name now that all parallel
741    // racers serialize through us — derive_name_from_cwd adds a
742    // path-hash suffix if the basename is already claimed by another
743    // cwd in the (now-stable) registry.
744    let registry = crate::session::read_registry().unwrap_or_default();
745    let name = crate::session::derive_name_from_cwd(&cwd, &registry);
746    let session_home = match crate::session::session_dir(&name) {
747        Ok(h) => h,
748        Err(_) => {
749            let _ = fs2::FileExt::unlock(&lock_file);
750            return;
751        }
752    };
753    let agent_card_path = session_home
754        .join("config")
755        .join("wire")
756        .join("agent-card.json");
757    let needs_init = !agent_card_path.exists();
758
759    if needs_init {
760        if let Err(e) = std::fs::create_dir_all(&session_home) {
761            eprintln!(
762                "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
763            );
764            let _ = fs2::FileExt::unlock(&lock_file);
765            return;
766        }
767        // v0.9: --offline; the surrounding session-spawn path runs
768        // try_allocate_local_slot afterward to attach an inbound slot
769        // when a local relay is available. Init itself stays slotless
770        // because it's a precursor step, not the final state.
771        match super::run_wire_with_home(&session_home, &["init", "--offline"]) {
772            Ok(status) if status.success() => {}
773            Ok(status) => {
774                eprintln!(
775                    "wire {label}: auto-init: `wire init` for `{name}` exited non-zero ({status}) — falling back to default identity"
776                );
777                let _ = fs2::FileExt::unlock(&lock_file);
778                return;
779            }
780            Err(e) => {
781                eprintln!(
782                    "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
783                );
784                let _ = fs2::FileExt::unlock(&lock_file);
785                return;
786            }
787        }
788        // Best-effort: allocate a local-relay slot so this auto-init'd
789        // session is addressable by sister sessions. Skipped silently when
790        // the local relay isn't running (the function itself reports to
791        // stderr). Auto-init'd sessions without endpoints can still
792        // surface their character but cannot receive pair_drops until the
793        // operator runs `wire bind-relay` or restarts the local relay.
794        try_allocate_local_slot(
795            &session_home,
796            &name,
797            "https://wireup.net",
798            "http://127.0.0.1:8771",
799        );
800    } else {
801        // Race loser path: peer already created the session. Surface
802        // this honestly so the operator can see we adopted rather than
803        // double-initialized.
804        if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
805            eprintln!(
806                "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
807            );
808        }
809    }
810    // v0.7.0-alpha.12 (review-fix #135 part 2): register cwd → name
811    // BEFORE releasing the auto-init lock. Pre-fix released the lock
812    // here and committed the registry update afterward — racers in
813    // OTHER cwds with the same basename would acquire the lock,
814    // read the registry (still without our entry), and derive the
815    // SAME name we just claimed. Live regression test caught it:
816    // two cwds /a/projx + /b/projx both got name "projx", both
817    // mapped to the same identity. Update the registry WHILE STILL
818    // holding the auto-init lock so the next racer sees our claim.
819    let cwd_key = crate::session::normalize_cwd_key(&cwd);
820    let name_for_reg = name.clone();
821    if let Err(e) = crate::session::update_registry(|reg| {
822        reg.by_cwd.insert(cwd_key, name_for_reg);
823        Ok(())
824    }) {
825        eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
826        // proceed — env var still gets set below
827    }
828    // NOW release the lock — racers waiting will see our registry
829    // entry on their re-read.
830    let _ = fs2::FileExt::unlock(&lock_file);
831
832    if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
833        eprintln!(
834            "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
835            cwd.display(),
836            session_home.display()
837        );
838    }
839    // SAFETY: caller contract is "before any thread spawn." MCP::run
840    // calls this immediately after `maybe_adopt_session_wire_home`.
841    unsafe {
842        std::env::set_var("WIRE_HOME", &session_home);
843    }
844}
845
846fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
847    // Check if a daemon is already alive in this session's WIRE_HOME.
848    // If so, no-op (let the existing process keep running).
849    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
850    if pidfile.exists() {
851        let bytes = std::fs::read(&pidfile).unwrap_or_default();
852        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
853            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
854        } else {
855            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
856        };
857        if let Some(p) = pid {
858            let alive = {
859                #[cfg(target_os = "linux")]
860                {
861                    std::path::Path::new(&format!("/proc/{p}")).exists()
862                }
863                #[cfg(not(target_os = "linux"))]
864                {
865                    std::process::Command::new("kill")
866                        .args(["-0", &p.to_string()])
867                        .output()
868                        .map(|o| o.status.success())
869                        .unwrap_or(false)
870                }
871            };
872            if alive {
873                return Ok(());
874            }
875        }
876    }
877
878    // Spawn `wire daemon` detached. The existing `cmd_daemon` writes the
879    // versioned pidfile; we just kick it off and return.
880    let bin = std::env::current_exe().with_context(|| "locating self exe")?;
881    let log_path = session_home.join("state").join("wire").join("daemon.log");
882    if let Some(parent) = log_path.parent() {
883        std::fs::create_dir_all(parent).ok();
884    }
885    let log_file = std::fs::OpenOptions::new()
886        .create(true)
887        .append(true)
888        .open(&log_path)
889        .with_context(|| format!("opening daemon log {log_path:?}"))?;
890    let log_err = log_file.try_clone()?;
891    std::process::Command::new(&bin)
892        .env("WIRE_HOME", session_home)
893        .env_remove("RUST_LOG")
894        .args(["daemon", "--interval", "5"])
895        .stdout(log_file)
896        .stderr(log_err)
897        .stdin(std::process::Stdio::null())
898        .spawn()
899        .with_context(|| "spawning session-local `wire daemon`")?;
900    Ok(())
901}
902
903pub(super) fn cmd_session_list(as_json: bool) -> Result<()> {
904    let items = crate::session::list_sessions()?;
905    if as_json {
906        println!("{}", serde_json::to_string(&items)?);
907        return Ok(());
908    }
909    if items.is_empty() {
910        println!("no sessions on this machine. `wire session new` to create one.");
911        return Ok(());
912    }
913    println!(
914        "{:<22} {:<24} {:<24} {:<10} CWD",
915        "PERSONA", "NAME", "HANDLE", "DAEMON"
916    );
917    for s in items {
918        // ANSI-escape-wrapped character takes more visual width than its
919        // displayed glyph count; pad based on the plain-text form, then
920        // wrap in escapes so the column lines up across rows.
921        let plain = s
922            .character
923            .as_ref()
924            .map(|c| c.short())
925            .unwrap_or_else(|| "?".to_string());
926        let colored = s
927            .character
928            .as_ref()
929            .map(|c| c.colored())
930            .unwrap_or_else(|| "?".to_string());
931        // Approximate display width: emoji renders as ~2 cells in most
932        // terminals; the rest are 1 cell each. We pad to 18 displayed
933        // chars (≈22 byte slots when counting emoji).
934        let displayed_width = plain.chars().count() + 1; // +1 emoji-wide compensation
935        let pad = 22usize.saturating_sub(displayed_width);
936        println!(
937            "{}{}  {:<24} {:<24} {:<10} {}",
938            colored,
939            " ".repeat(pad),
940            s.name,
941            s.handle.as_deref().unwrap_or("?"),
942            if s.daemon_running { "running" } else { "down" },
943            s.cwd.as_deref().unwrap_or("(no cwd registered)"),
944        );
945    }
946    Ok(())
947}
948
949/// v0.5.19: `wire session list-local` — sister-session discovery.
950///
951/// For each on-disk session, read its `relay-state.json` and surface
952/// the ones that have a Local-scope endpoint (allocated via
953/// `wire session new --with-local`). Group by the local-relay URL so
954/// the operator can see at a glance which sessions are mutually
955/// reachable over the same loopback relay.
956///
957/// Read-only, no daemon contact. Useful as the prelude to teaming /
958/// pairing same-box sister claudes (see also `wire session
959/// pair-all-local` once implemented).
960pub(super) fn cmd_session_list_local(as_json: bool) -> Result<()> {
961    let listing = crate::session::list_local_sessions()?;
962    if as_json {
963        println!("{}", serde_json::to_string(&listing)?);
964        return Ok(());
965    }
966
967    if listing.local.is_empty() && listing.federation_only.is_empty() {
968        println!(
969            "no sessions on this machine. `wire session new --with-local` to create one \
970             with a local-relay endpoint (start the relay first: \
971             `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
972        );
973        return Ok(());
974    }
975
976    if listing.local.is_empty() {
977        println!(
978            "no sister sessions reachable via a local relay. \
979             Re-run `wire session new --with-local` to add a Local endpoint, or \
980             start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
981        );
982    } else {
983        // Stable iteration order: sort the relay URLs.
984        let mut keys: Vec<&String> = listing.local.keys().collect();
985        keys.sort();
986        for relay_url in keys {
987            let group = &listing.local[relay_url];
988            println!("LOCAL RELAY: {relay_url}");
989            println!("  {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
990            for s in group {
991                println!(
992                    "  {:<24} {:<32} {:<10} {}",
993                    s.name,
994                    s.handle.as_deref().unwrap_or("?"),
995                    if s.daemon_running { "running" } else { "down" },
996                    s.cwd.as_deref().unwrap_or("(no cwd registered)"),
997                );
998            }
999            println!();
1000        }
1001    }
1002
1003    if !listing.federation_only.is_empty() {
1004        println!("federation-only (no local endpoint):");
1005        for s in &listing.federation_only {
1006            println!(
1007                "  {:<24} {:<32} {}",
1008                s.name,
1009                s.handle.as_deref().unwrap_or("?"),
1010                s.cwd.as_deref().unwrap_or("(no cwd registered)"),
1011            );
1012        }
1013    }
1014    Ok(())
1015}
1016
1017/// v0.6.0 (issue #12): orchestrate bilateral pair across every sister
1018/// session that has a Local-scope endpoint. Skips already-paired
1019/// pairs; reports a per-pair outcome JSON suitable for scripting.
1020///
1021/// Same-uid trust anchor: the caller owns every session enumerated by
1022/// `list_local_sessions`, so the operator running this command IS the
1023/// consent for both sides. The bilateral SAS / network-level handshake
1024/// assumes strangers; same-uid sister sessions are not strangers.
1025///
1026/// Per-pair flow (sequential to keep relay-side load + log clarity):
1027///   1. WIRE_HOME=A wire add <B-handle>@<host>  (writes pending-inbound on B)
1028///   2. WIRE_HOME=A wire push --json            (sends pair_drop to relay)
1029///   3. sleep settle_secs                       (pair_drop reaches B)
1030///   4. WIRE_HOME=B wire pull --json            (B receives pair_drop)
1031///   5. WIRE_HOME=B wire accept <A-bare>   (B pins A, sends ack)
1032///   6. WIRE_HOME=B wire push --json            (sends pair_drop_ack)
1033///   7. sleep settle_secs                       (ack reaches A)
1034///   8. WIRE_HOME=A wire pull --json            (A pins B)
1035pub(super) fn cmd_session_pair_all_local(
1036    settle_secs: u64,
1037    federation_relay: &str,
1038    as_json: bool,
1039) -> Result<()> {
1040    use std::collections::BTreeSet;
1041    use std::time::Duration;
1042
1043    let listing = crate::session::list_local_sessions()?;
1044    // Flatten + dedup by session NAME (same session can appear under
1045    // multiple local-relay URLs if it advertises two local endpoints;
1046    // rare, but pair each pair exactly once).
1047    let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
1048        Default::default();
1049    for group in listing.local.into_values() {
1050        for s in group {
1051            by_name.entry(s.name.clone()).or_insert(s);
1052        }
1053    }
1054    let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
1055
1056    if sessions.len() < 2 {
1057        let msg = format!(
1058            "{} sister session(s) with a local endpoint — need at least 2 to pair.",
1059            sessions.len()
1060        );
1061        if as_json {
1062            println!(
1063                "{}",
1064                serde_json::to_string(&json!({
1065                    "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
1066                    "pairs_attempted": 0,
1067                    "pairs_succeeded": 0,
1068                    "pairs_skipped_already_paired": 0,
1069                    "pairs_failed": 0,
1070                    "note": msg,
1071                }))?
1072            );
1073        } else {
1074            println!("{msg}");
1075            if let Some(s) = sessions.first() {
1076                println!("  - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
1077            }
1078            println!("Use `wire session new --with-local` to add more.");
1079        }
1080        return Ok(());
1081    }
1082
1083    let fed_host = super::host_of_url(federation_relay);
1084    if fed_host.is_empty() {
1085        bail!(
1086            "federation_relay `{federation_relay}` has no parseable host — \
1087             pass a full URL like `https://wireup.net`."
1088        );
1089    }
1090
1091    // Enumerate unordered pairs deterministically by session name.
1092    let mut attempted = 0u32;
1093    let mut succeeded = 0u32;
1094    let mut skipped_already = 0u32;
1095    let mut failed = 0u32;
1096    let mut per_pair: Vec<Value> = Vec::new();
1097
1098    for i in 0..sessions.len() {
1099        for j in (i + 1)..sessions.len() {
1100            let a = &sessions[i];
1101            let b = &sessions[j];
1102            attempted += 1;
1103
1104            // Already-paired check: if A's relay-state has B's CARD
1105            // HANDLE in peers AND vice versa, skip. v0.11: peer keys
1106            // are character handles (not session names), so we use
1107            // each side's handle field (already on the LocalSessionView)
1108            // for the lookup rather than the session name.
1109            let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
1110            let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
1111            let a_pinned_b = super::session_has_peer(&a.home_dir, b_handle);
1112            let b_pinned_a = super::session_has_peer(&b.home_dir, a_handle);
1113            if a_pinned_b && b_pinned_a {
1114                skipped_already += 1;
1115                per_pair.push(json!({
1116                    "from": a.name,
1117                    "to": b.name,
1118                    "status": "already_paired",
1119                }));
1120                continue;
1121            }
1122
1123            let pair_result = drive_bilateral_pair(
1124                &a.home_dir,
1125                &a.name,
1126                &b.home_dir,
1127                &b.name,
1128                &fed_host,
1129                federation_relay,
1130                settle_secs,
1131            );
1132
1133            match pair_result {
1134                Ok(()) => {
1135                    succeeded += 1;
1136                    per_pair.push(json!({
1137                        "from": a.name,
1138                        "to": b.name,
1139                        "status": "paired",
1140                    }));
1141                }
1142                Err(e) => {
1143                    failed += 1;
1144                    let detail = format!("{e:#}");
1145                    per_pair.push(json!({
1146                        "from": a.name,
1147                        "to": b.name,
1148                        "status": "failed",
1149                        "error": detail,
1150                    }));
1151                }
1152            }
1153
1154            // Brief settle between pairs so we don't slam the relay
1155            // with N(N-1) parallel requests.
1156            std::thread::sleep(Duration::from_millis(200));
1157        }
1158    }
1159
1160    let _ = BTreeSet::<String>::new(); // silence unused-import lint if any
1161    let summary = json!({
1162        "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
1163        "pairs_attempted": attempted,
1164        "pairs_succeeded": succeeded,
1165        "pairs_skipped_already_paired": skipped_already,
1166        "pairs_failed": failed,
1167        "results": per_pair,
1168    });
1169    if as_json {
1170        println!("{}", serde_json::to_string(&summary)?);
1171    } else {
1172        println!(
1173            "wire session pair-all-local: {} session(s), {} pair(s) attempted",
1174            sessions.len(),
1175            attempted
1176        );
1177        println!("  paired:                 {succeeded}");
1178        println!("  skipped (already pinned): {skipped_already}");
1179        println!("  failed:                 {failed}");
1180        for entry in summary["results"].as_array().unwrap_or(&vec![]) {
1181            let from = entry["from"].as_str().unwrap_or("?");
1182            let to = entry["to"].as_str().unwrap_or("?");
1183            let status = entry["status"].as_str().unwrap_or("?");
1184            let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
1185            if err.is_empty() {
1186                println!("  {from:<24} ↔ {to:<24} {status}");
1187            } else {
1188                println!("  {from:<24} ↔ {to:<24} {status} — {err}");
1189            }
1190        }
1191    }
1192    Ok(())
1193}
1194
1195/// Drive one bilateral pair handshake between two sister sessions
1196/// using their session home dirs as `WIRE_HOME`. Sequential 8-step
1197/// flow so failures bubble up at the offending step, not buried in
1198/// a parallel race. See `cmd_session_pair_all_local` docstring.
1199///
1200/// v0.6.6: step 1 (the `wire add`) uses `--local-sister` instead of
1201/// federation `.well-known/wire/agent` resolution. Reads B's card +
1202/// endpoints directly off disk under `b_home` and pins them. This
1203/// makes pair-all-local work for sister sessions whose federation
1204/// handle is unclaimable (reserved nicks like `wire` / `slancha`) and
1205/// for sessions created with `wire session new --local-only`
1206/// (no federation slot at all). The `_federation_relay` / `_fed_host`
1207/// parameters are retained for callers that want to log them but
1208/// the handshake itself no longer touches federation.
1209fn drive_bilateral_pair(
1210    a_home: &std::path::Path,
1211    a_name: &str,
1212    b_home: &std::path::Path,
1213    b_name: &str,
1214    _fed_host: &str,
1215    _federation_relay: &str,
1216    settle_secs: u64,
1217) -> Result<()> {
1218    use std::time::Duration;
1219    let bin = std::env::current_exe().context("locating self exe")?;
1220
1221    let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
1222        let out = std::process::Command::new(&bin)
1223            .env("WIRE_HOME", home)
1224            .env_remove("RUST_LOG")
1225            .args(args)
1226            .output()
1227            .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
1228        if !out.status.success() {
1229            bail!(
1230                "`wire {}` failed: stderr={}",
1231                args.join(" "),
1232                String::from_utf8_lossy(&out.stderr).trim()
1233            );
1234        }
1235        Ok(())
1236    };
1237
1238    // v0.11: each session's agent-card.handle is the DID-derived
1239    // character, not the session name. wire-accept lookups key on the
1240    // CARD HANDLE, so we discover each side's canonical handle from
1241    // its agent-card on disk before driving the pair flow.
1242    let read_card_handle = |home: &std::path::Path| -> Result<String> {
1243        let card_path = home.join("config").join("wire").join("agent-card.json");
1244        let bytes = std::fs::read(&card_path)
1245            .with_context(|| format!("reading agent-card at {card_path:?}"))?;
1246        let card: Value = serde_json::from_slice(&bytes)?;
1247        card.get("handle")
1248            .and_then(Value::as_str)
1249            .map(str::to_string)
1250            .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
1251    };
1252    let a_handle = read_card_handle(a_home)
1253        .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
1254    let b_handle = read_card_handle(b_home)
1255        .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
1256
1257    // 1. A initiates via --local-sister (uses the session NAME for
1258    // the registry lookup; cmd_add_local_sister auto-resolves
1259    // session→handle internally).
1260    run(a_home, &["add", b_name, "--local-sister", "--json"])
1261        .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
1262
1263    // 3. settle so pair_drop reaches B's slot
1264    std::thread::sleep(Duration::from_secs(settle_secs));
1265
1266    // 4. B pulls pair_drop → 5. B accept (pins A by CARD HANDLE,
1267    // not by session name — under v0.11 these differ) → 6. B push ack
1268    run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
1269    run(b_home, &["accept", &a_handle, "--json"]).with_context(|| {
1270        format!("step 5/8: {b_name} `wire accept {a_handle}` (a session={a_name})")
1271    })?;
1272    run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
1273
1274    // 7. settle so ack reaches A's slot
1275    std::thread::sleep(Duration::from_secs(settle_secs));
1276
1277    // 8. A pulls ack (pins B by CARD HANDLE)
1278    run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
1279    // suppress unused warning when both handles are consumed
1280    let _ = &b_handle;
1281
1282    Ok(())
1283}
1284
1285pub(super) fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
1286    let name = resolve_session_name(name_arg)?;
1287    let session_home = crate::session::session_dir(&name)?;
1288    if !session_home.exists() {
1289        bail!(
1290            "no session named {name:?} on this machine. `wire session list` to enumerate, \
1291             `wire session new {name}` to create."
1292        );
1293    }
1294    if as_json {
1295        println!(
1296            "{}",
1297            serde_json::to_string(&json!({
1298                "name": name,
1299                "home_dir": session_home.to_string_lossy(),
1300                "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
1301            }))?
1302        );
1303    } else {
1304        println!("export WIRE_HOME={}", session_home.to_string_lossy());
1305    }
1306    Ok(())
1307}
1308
1309pub(super) fn cmd_session_current(as_json: bool) -> Result<()> {
1310    let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
1311    let registry = crate::session::read_registry().unwrap_or_default();
1312    let cwd_key = crate::session::normalize_cwd_key(&cwd);
1313    // Backward-compat: O(n) normalized scan on read-miss. Mirrors the
1314    // same pattern in session::derive_name_from_cwd /
1315    // detect_session_wire_home — handles both consistent-casing and
1316    // cross-casing upgraders (see session.rs for the full rationale).
1317    let name = registry
1318        .by_cwd
1319        .get(&cwd_key)
1320        .or_else(|| {
1321            registry
1322                .by_cwd
1323                .iter()
1324                .find(|(k, _)| {
1325                    crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
1326                })
1327                .map(|(_, v)| v)
1328        })
1329        .cloned();
1330    if as_json {
1331        println!(
1332            "{}",
1333            serde_json::to_string(&json!({
1334                "cwd": cwd_key,
1335                "session": name,
1336            }))?
1337        );
1338    } else if let Some(n) = name {
1339        println!("{n}");
1340    } else {
1341        println!("(no session registered for this cwd)");
1342    }
1343    Ok(())
1344}
1345
1346pub(super) fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
1347    let name = crate::session::sanitize_name(name_arg);
1348    let session_home = crate::session::session_dir(&name)?;
1349    if !session_home.exists() {
1350        if as_json {
1351            println!(
1352                "{}",
1353                serde_json::to_string(&json!({
1354                    "name": name,
1355                    "destroyed": false,
1356                    "reason": "no such session",
1357                }))?
1358            );
1359        } else {
1360            println!("no session named {name:?} — nothing to destroy.");
1361        }
1362        return Ok(());
1363    }
1364    if !force {
1365        bail!(
1366            "destroying session {name:?} would delete its keypair + state irrecoverably. \
1367             Pass --force to confirm."
1368        );
1369    }
1370
1371    // Kill the session-local daemon if alive.
1372    let pidfile = session_home.join("state").join("wire").join("daemon.pid");
1373    if let Ok(bytes) = std::fs::read(&pidfile) {
1374        let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
1375            v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
1376        } else {
1377            String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
1378        };
1379        if let Some(p) = pid {
1380            let _ = std::process::Command::new("kill")
1381                .args(["-TERM", &p.to_string()])
1382                .output();
1383        }
1384    }
1385
1386    std::fs::remove_dir_all(&session_home)
1387        .with_context(|| format!("removing session dir {session_home:?}"))?;
1388
1389    // Strip from registry.
1390    let mut registry = crate::session::read_registry().unwrap_or_default();
1391    registry.by_cwd.retain(|_, v| v != &name);
1392    crate::session::write_registry(&registry)?;
1393
1394    if as_json {
1395        println!(
1396            "{}",
1397            serde_json::to_string(&json!({
1398                "name": name,
1399                "destroyed": true,
1400            }))?
1401        );
1402    } else {
1403        println!("destroyed session {name:?}.");
1404    }
1405    Ok(())
1406}
1407
1408#[cfg(test)]
1409mod coerce_object_root_tests {
1410    use super::coerce_object_root;
1411    use serde_json::json;
1412
1413    #[test]
1414    fn non_object_roots_are_coerced_to_empty_object() {
1415        for mut corrupt in [
1416            json!([]),
1417            json!("corrupt"),
1418            json!(42),
1419            serde_json::Value::Null,
1420        ] {
1421            coerce_object_root(&mut corrupt);
1422            assert!(corrupt.is_object(), "root not coerced: {corrupt}");
1423        }
1424    }
1425
1426    #[test]
1427    fn object_root_is_left_untouched() {
1428        let mut state = json!({"self": {"endpoints": [1, 2]}});
1429        coerce_object_root(&mut state);
1430        assert_eq!(state, json!({"self": {"endpoints": [1, 2]}}));
1431    }
1432}