crates/signaltty-server/tests/integration.rs

//! Phase 1 integration tests with real PTYs (§23):
//! spawn/read/exit, detach/reattach, resize, signals, malformed IPC,
//! notifications/attention, concurrent clients, splits, restart/restore.

use std::time::Duration;

use serde_json::json;
use signaltty_testkit::{TestClient, TestServer};

async fn new_pane(c: &mut TestClient, argv: Vec<&str>) -> (String, String) {
    let w = c
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    let ws = w["workspace"]["id"].as_str().unwrap().to_string();
    let p = c
        .call("pane.spawn", json!({"workspace_id": ws, "argv": argv}))
        .await
        .unwrap();
    (ws, p["pane"]["id"].as_str().unwrap().to_string())
}

async fn wait_for_text(c: &mut TestClient, pane: &str, marker: &str, timeout: Duration) {
    let start = std::time::Instant::now();
    loop {
        let r = c
            .call(
                "pane.read",
                json!({"pane_id": pane, "mode": "tail", "lines": 50}),
            )
            .await
            .unwrap();
        if r["text"].as_str().unwrap_or("").contains(marker) {
            return;
        }
        assert!(
            start.elapsed() < timeout,
            "timed out waiting for {marker:?}"
        );
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
}

#[tokio::test]
async fn spawn_echo_and_read() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sh", "-c", "echo hello-signaltty"]).await;
    wait_for_text(&mut c, &pane, "hello-signaltty", Duration::from_secs(5)).await;
    // Process exits; wait observes it.
    let w = c
        .call(
            "wait",
            json!({"pane_id": pane, "until": "exited", "timeout_s": 5}),
        )
        .await
        .unwrap();
    assert_eq!(w["satisfied"], true);
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["live"]["state"], "exited");
    assert_eq!(p["pane"]["live"]["code"], 0);
    // Generic one-shot exit 0 maps to done so wait --until done can observe it.
    assert_eq!(p["pane"]["lifecycle"], "done");
    srv.shutdown().await;
}

#[tokio::test]
async fn wait_until_done_observes_fast_one_shot_exit() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sh", "-c", "exit 0"]).await;
    let w = c
        .call(
            "wait",
            json!({"pane_id": pane, "until": "done", "timeout_s": 5}),
        )
        .await
        .unwrap();
    assert_eq!(w["satisfied"], true);
    assert_eq!(w["outcome"], "done");
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["live"]["state"], "exited");
    assert_eq!(p["pane"]["live"]["code"], 0);
    assert_eq!(p["pane"]["lifecycle"], "done");
    srv.shutdown().await;
}

#[tokio::test]
async fn input_roundtrip_and_exit_code() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(
        &mut c,
        vec!["sh", "-c", "read line; echo got:$line; exit 7"],
    )
    .await;
    tokio::time::sleep(Duration::from_millis(200)).await;
    let data = base64_encode("ping\n");
    let r = c
        .call("pane.input", json!({"pane_id": pane, "data_b64": data}))
        .await
        .unwrap();
    assert_eq!(r["written"], 5);
    wait_for_text(&mut c, &pane, "got:ping", Duration::from_secs(5)).await;
    c.call(
        "wait",
        json!({"pane_id": pane, "until": "exited", "timeout_s": 5}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["live"]["code"], 7);
    srv.shutdown().await;
}

fn base64_encode(s: &str) -> String {
    // Minimal base64 to avoid adding deps to the test target.
    const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let bytes = s.as_bytes();
    let mut out = String::new();
    for chunk in bytes.chunks(3) {
        let mut n = 0u32;
        for (i, &b) in chunk.iter().enumerate() {
            n |= (b as u32) << (16 - 8 * i);
        }
        let pad = 3 - chunk.len();
        for i in 0..4 - pad {
            out.push(ALPH[((n >> (18 - 6 * i)) & 63) as usize] as char);
        }
        for _ in 0..pad {
            out.push('=');
        }
    }
    out
}

#[tokio::test]
async fn detach_reattach_keeps_process() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sh", "-c", "echo first; sleep 30"]).await;
    wait_for_text(&mut c, &pane, "first", Duration::from_secs(5)).await;
    // Attach (snapshot), then drop the client = crash/disconnect.
    let a = c
        .call("pane.attach", json!({"pane_id": pane}))
        .await
        .unwrap();
    assert!(a.get("snapshot_b64").is_some());
    drop(c);
    // New client: same pane, still live, history intact.
    let mut c2 = srv.client().await;
    let p = c2.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["live"]["state"], "live");
    let r = c2
        .call(
            "pane.read",
            json!({"pane_id": pane, "mode": "tail", "lines": 10}),
        )
        .await
        .unwrap();
    assert!(r["text"].as_str().unwrap().contains("first"));
    srv.shutdown().await;
}

#[tokio::test]
async fn resize_and_signal() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let r = c
        .call(
            "pane.resize",
            json!({"pane_id": pane, "cols": 100, "rows": 30}),
        )
        .await
        .unwrap();
    assert_eq!(r["pane"]["pty_size"]["cols"], 100);
    // Out-of-range clamps instead of failing.
    let r = c
        .call(
            "pane.resize",
            json!({"pane_id": pane, "cols": 5000, "rows": 1}),
        )
        .await
        .unwrap();
    assert_eq!(r["pane"]["pty_size"]["cols"], 500);
    c.call("pane.signal", json!({"pane_id": pane, "signal": "TERM"}))
        .await
        .unwrap();
    c.call(
        "wait",
        json!({"pane_id": pane, "until": "exited", "timeout_s": 5}),
    )
    .await
    .unwrap();
    // Signalling a dead pane reports PANE_EXITED.
    let err = c
        .call("pane.signal", json!({"pane_id": pane, "signal": "INT"}))
        .await
        .unwrap_err();
    assert!(err.starts_with("PANE_EXITED"), "{err}");
    srv.shutdown().await;
}

#[tokio::test]
async fn malformed_ipc_and_versioning() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    // Invalid JSON.
    let resp: serde_json::Value =
        serde_json::from_str(c.raw_roundtrip("not json").await.trim()).unwrap();
    assert_eq!(resp["ok"], false);
    assert_eq!(resp["error"]["code"], "BAD_PARAMS");
    // Wrong protocol.
    let resp: serde_json::Value = serde_json::from_str(
        c.raw_roundtrip(
            r#"{"protocol":"signaltty/99","id":"1","method":"server.status","params":{}}"#,
        )
        .await
        .trim(),
    )
    .unwrap();
    assert_eq!(resp["error"]["code"], "BAD_PROTOCOL");
    // Unknown method.
    let err = c.call("nope.nope", json!({})).await.unwrap_err();
    assert!(err.starts_with("UNKNOWN_METHOD"), "{err}");
    // Missing resource.
    let err = c
        .call("pane.get", json!({"pane_id": "pane_missing"}))
        .await
        .unwrap_err();
    assert!(err.starts_with("NO_SUCH_PANE"), "{err}");
    // Server survives all of it.
    assert!(c.call("server.status", json!({})).await.is_ok());
    srv.shutdown().await;
}

#[tokio::test]
async fn notify_attention_and_next_unread() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    // Subscribe BEFORE notifying.
    let mut sub = srv.client().await;
    sub.call(
        "subscribe",
        json!({"events": ["notification.*", "attention.*"]}),
    )
    .await
    .unwrap();
    c.call(
        "notify",
        json!({"pane_id": pane, "title": "Codex needs input", "body": "review?", "severity": "error"}),
    )
    .await
    .unwrap();
    // Drain two events: notification.created + attention.created.
    let evs = drain_events(&mut sub, 2, Duration::from_secs(5)).await;
    let names: Vec<_> = evs
        .iter()
        .map(|e| e["event"].as_str().unwrap().to_string())
        .collect();
    assert!(
        names.contains(&"notification.created".to_string()),
        "{names:?}"
    );
    assert!(
        names.contains(&"attention.created".to_string()),
        "{names:?}"
    );
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["attention"], "error");
    assert!(p["pane"]["last_message"]
        .as_str()
        .unwrap()
        .contains("Codex"));
    // Lifecycle untouched by attention.
    assert_eq!(p["pane"]["lifecycle"], "unknown");
    // next_unread points here.
    let f = c.call("focus.next_unread", json!({})).await.unwrap();
    assert_eq!(f["pane_id"], pane);
    // mark_seen clears attention only.
    c.call("pane.mark_seen", json!({"pane_id": pane}))
        .await
        .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["attention"], "none");
    let f = c.call("focus.next_unread", json!({})).await.unwrap();
    assert!(f["pane_id"].is_null());
    srv.shutdown().await;
}

async fn drain_events(
    c: &mut TestClient,
    count: usize,
    timeout: Duration,
) -> Vec<serde_json::Value> {
    // TestClient::call skips events, so subscribe_collect must be used on a
    // fresh subscription; here we re-subscribe and wait for NEW events only
    // if count not yet satisfied. Simpler: use raw reads via a second helper.
    // We already subscribed; read raw lines directly.
    c.read_events(count, timeout).await
}

#[tokio::test]
async fn osc777_from_pty_raises_notification() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(
        &mut c,
        vec![
            "sh",
            "-c",
            r"printf '\033]777;notify;Build;done-ok\007'; sleep 30",
        ],
    )
    .await;
    // Poll until attention flips.
    let start = std::time::Instant::now();
    loop {
        let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
        if p["pane"]["attention"] == "unread" {
            assert!(p["pane"]["last_message"]
                .as_str()
                .unwrap()
                .contains("done-ok"));
            break;
        }
        assert!(start.elapsed() < Duration::from_secs(5), "no OSC attention");
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    srv.shutdown().await;
}

#[tokio::test]
async fn split_layout_and_concurrent_clients() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    // Second client splits concurrently.
    let mut c2 = srv.client().await;
    let s = c2
        .call(
            "pane.split",
            json!({"pane_id": pane, "direction": "down", "argv": ["sleep", "30"]}),
        )
        .await
        .unwrap();
    let pane2 = s["pane"]["id"].as_str().unwrap().to_string();
    assert_ne!(pane, pane2);
    // Layout contains both.
    let g = c.call("pane.get", json!({"pane_id": pane2})).await.unwrap();
    let tab_id = g["pane"]["tab_id"].as_str().unwrap().to_string();
    let _ = tab_id;
    // Both live from either client's view.
    let st = c.call("server.status", json!({})).await.unwrap();
    assert_eq!(st["live_panes"], 2);
    let st2 = c2.call("server.status", json!({})).await.unwrap();
    assert_eq!(st2["live_panes"], 2);
    srv.shutdown().await;
}

#[tokio::test]
async fn restart_restores_structure_without_processes() {
    let mut srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sh", "-c", "echo persist-me; sleep 30"]).await;
    wait_for_text(&mut c, &pane, "persist-me", Duration::from_secs(5)).await;
    c.call(
        "report-session",
        json!({"pane_id": pane, "agent_session_id": "sess-123", "agent": "codex"}),
    )
    .await
    .unwrap();
    c.call(
        "notify",
        json!({"pane_id": pane, "title": "t", "body": "b"}),
    )
    .await
    .unwrap();
    let pane_id = pane.clone();
    drop(c);
    srv.restart().await;
    let mut c = srv.client().await;
    let p = c
        .call("pane.get", json!({"pane_id": pane_id}))
        .await
        .unwrap();
    // Honest restore: no process, structure + metadata + tail kept.
    assert_eq!(p["pane"]["live"]["state"], "exited");
    assert!(p["pane"]["restore_state"] == "RESTORED" || p["pane"]["restore_state"] == "RESUMABLE");
    assert_eq!(p["pane"]["agent"]["agent_session_id"], "sess-123");
    assert_eq!(p["pane"]["agent"]["kind"], "codex");
    assert_eq!(p["pane"]["attention"], "unread"); // unread stays unread
    let r = c
        .call(
            "pane.read",
            json!({"pane_id": pane_id, "mode": "tail", "lines": 10}),
        )
        .await
        .unwrap();
    assert!(r["text"].as_str().unwrap().contains("persist-me"));
    srv.shutdown().await;
}

fn decision_payload(id: &str) -> serde_json::Value {
    json!({
        "id": id, "prompt": "Allow rm -rf /tmp/x?",
        "options": [
            {"id": "once", "label": "Once"},
            {"id": "always", "label": "Always"},
            {"id": "deny", "label": "Deny"},
        ],
    })
}

#[tokio::test]
async fn decision_answer_delivers_bytes_and_consumes() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    // A codex pane whose child is `cat`: echoes whatever the answer
    // channel types (`agent_hint` is how tests stand in for the real CLI).
    let w = c
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    let ws = w["workspace"]["id"].as_str().unwrap();
    let p = c
        .call(
            "pane.spawn",
            json!({"workspace_id": ws, "argv": ["cat"], "agent_hint": "codex"}),
        )
        .await
        .unwrap();
    let pane = p["pane"]["id"].as_str().unwrap().to_string();
    tokio::time::sleep(Duration::from_millis(200)).await;

    c.call(
        "hook-event",
        json!({"agent": "codex", "event": "PermissionRequest", "pane_id": pane,
               "decision": decision_payload("d1")}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["pending_decision"]["id"], "d1");
    assert_eq!(p["pane"]["pending_decision"]["answerable"], true);
    assert_eq!(
        p["pane"]["pending_decision"]["options"]
            .as_array()
            .unwrap()
            .len(),
        3
    );

    // Answering "Once" types "1\\n": cat echoes it back.
    let r = c
        .call(
            "decision.answer",
            json!({"pane_id": pane, "decision_id": "d1", "option_id": "once"}),
        )
        .await
        .unwrap();
    assert_eq!(r["answered"], true);
    assert_eq!(r["attention"], "none");
    assert_eq!(r["lifecycle"], "blocked");
    wait_for_text(&mut c, &pane, "1", Duration::from_secs(5)).await;

    // Consumed: the bar is gone and a repeat never redelivers.
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert!(p["pane"].get("pending_decision").is_none());
    assert_eq!(p["pane"]["attention"], "none");
    assert_eq!(p["pane"]["lifecycle"], "blocked");
    let err = c
        .call(
            "decision.answer",
            json!({"pane_id": pane, "decision_id": "d1", "option_id": "once"}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("NO_SUCH_DECISION"), "{err}");
    srv.shutdown().await;
}

#[tokio::test]
async fn decision_prose_supersede_and_clearing() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;

    c.call(
        "hook-event",
        json!({"agent": "codex", "event": "PermissionRequest", "pane_id": pane,
               "decision": decision_payload("d1")}),
    )
    .await
    .unwrap();
    // Prose-only attention neither fakes buttons nor drops the real ones.
    c.call(
        "hook-event",
        json!({"agent": "codex", "event": "PermissionRequest", "pane_id": pane,
               "message": "approval requested"}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["pending_decision"]["id"], "d1");

    // Newer decision supersedes: latest wins.
    c.call(
        "hook-event",
        json!({"agent": "codex", "event": "PermissionRequest", "pane_id": pane,
               "decision": decision_payload("d2")}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["pending_decision"]["id"], "d2");
    // The superseded id is stale, never redeliverable.
    let err = c
        .call(
            "decision.answer",
            json!({"pane_id": pane, "decision_id": "d1", "option_id": "once"}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("NO_SUCH_DECISION"), "{err}");

    // The agent moving on (out of blocked) drops the bar.
    c.call(
        "hook-event",
        json!({"agent": "codex", "event": "UserPromptSubmit", "pane_id": pane}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert!(p["pane"].get("pending_decision").is_none());

    // Reading an approval preserves its decision and required attention.
    c.call(
        "hook-event",
        json!({"agent": "codex", "event": "PermissionRequest", "pane_id": pane,
               "decision": decision_payload("d3")}),
    )
    .await
    .unwrap();
    c.call("pane.mark_seen", json!({"pane_id": pane}))
        .await
        .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["pending_decision"]["id"], "d3");
    assert_eq!(p["pane"]["attention"], "permission_required");
    assert!(p["pane"]["last_seen_at"].is_string());
    let attached = c
        .call("pane.attach", json!({"pane_id": pane}))
        .await
        .unwrap();
    assert_eq!(attached["attention"], "permission_required");
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["pending_decision"]["id"], "d3");

    // Unknown option is a shape error; the decision survives it.
    c.call(
        "hook-event",
        json!({"agent": "codex", "event": "PermissionRequest", "pane_id": pane,
               "decision": decision_payload("d4")}),
    )
    .await
    .unwrap();
    let err = c
        .call(
            "decision.answer",
            json!({"pane_id": pane, "decision_id": "d4", "option_id": "maybe"}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("BAD_PARAMS"), "{err}");
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["pending_decision"]["id"], "d4");

    // Exiting the child drops the bar with it.
    c.call("pane.signal", json!({"pane_id": pane, "signal": "KILL"}))
        .await
        .unwrap();
    c.call(
        "wait",
        json!({"pane_id": pane, "until": "exited", "timeout_s": 5}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert!(p["pane"].get("pending_decision").is_none());
    srv.shutdown().await;
}

#[tokio::test]
async fn decision_without_channel_is_read_only() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    c.call(
        "hook-event",
        json!({"agent": "claude", "event": "Notification", "pane_id": pane,
               "decision": decision_payload("d1")}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["pending_decision"]["answerable"], false);
    // Server refuses to guess a delivery channel; the decision survives.
    let err = c
        .call(
            "decision.answer",
            json!({"pane_id": pane, "decision_id": "d1", "option_id": "once"}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("BAD_PARAMS"), "{err}");
    assert!(err.contains("no answer channel"), "{err}");
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["pending_decision"]["id"], "d1");
    srv.shutdown().await;
}

fn write_manifest(dir: &std::path::Path, name: &str, body: &str) {
    std::fs::create_dir_all(dir).unwrap();
    std::fs::write(dir.join(name), body).unwrap();
}

#[tokio::test]
async fn manifest_overlay_detects_and_classifies() {
    let dir = std::env::temp_dir().join(format!(
        "signaltty-agents-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    write_manifest(
        &dir,
        "wrap.toml",
        r#"
[agent]
kind = "codex"
binaries = ["sleep"]

[lifecycle.PingTest]
lifecycle = "working"
message = "future says hi"
"#,
    );
    // Malformed files never block startup (isolated, logged, skipped).
    write_manifest(&dir, "broken.toml", "[agent\nkind = ");
    let srv = TestServer::start_with_dirs(None, Some(&dir)).await;
    let mut c = srv.client().await;

    // Spawn-time detection honors overlay binaries.
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["agent"]["kind"], "codex");

    // Overlay hook maps classify; unmapped hooks fall through to builtin.
    let r = c
        .call(
            "hook-event",
            json!({"agent": "codex", "event": "PingTest", "pane_id": pane}),
        )
        .await
        .unwrap();
    assert_eq!(r["lifecycle"], "working");
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["last_message"], "future says hi");
    let r = c
        .call(
            "hook-event",
            json!({"agent": "codex", "event": "Stop", "pane_id": pane}),
        )
        .await
        .unwrap();
    assert_eq!(r["lifecycle"], "done");
    srv.shutdown().await;
    std::fs::remove_dir_all(&dir).ok();
}

#[tokio::test]
async fn procscan_promotes_through_shell_and_follows_cwd() {
    let dir = std::env::temp_dir().join(format!(
        "signaltty-agents-proc-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    write_manifest(
        &dir,
        "wrap.toml",
        "[agent]\nkind = \"codex\"\nbinaries = [\"sleep\"]\n",
    );
    let srv = TestServer::start_with_dirs(None, Some(&dir)).await;
    let mut c = srv.client().await;
    // Spawned as `sh` (generic); the tick sees through to `sleep`.
    let w = c
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    let ws = w["workspace"]["id"].as_str().unwrap();
    let p = c
        .call(
            "pane.spawn",
            json!({"workspace_id": ws, "argv": ["sh", "-c", "cd /; exec sleep 30"]}),
        )
        .await
        .unwrap();
    let pane = p["pane"]["id"].as_str().unwrap().to_string();
    // Promotion + cwd follow land within two ticks.
    let start = std::time::Instant::now();
    loop {
        let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
        if p["pane"]["agent"]["kind"] == "codex" && p["pane"]["cwd"] == "/" {
            break;
        }
        assert!(
            start.elapsed() < Duration::from_secs(25),
            "no promotion: {p}"
        );
        tokio::time::sleep(Duration::from_millis(500)).await;
    }
    // Afterwards the tick is quiet: nothing pushed for a full interval.
    let mut sub = srv.client().await;
    sub.call("subscribe", json!({"events": ["pane.*"]}))
        .await
        .unwrap();
    let heard = tokio::time::timeout(
        Duration::from_secs(11),
        sub.read_events(1, Duration::from_secs(30)),
    )
    .await;
    assert!(heard.is_err(), "tick must be quiet, got {heard:?}");
    srv.shutdown().await;
    std::fs::remove_dir_all(&dir).ok();
}

#[tokio::test]
async fn workspace_handles_collide_resolve_and_migrate() {
    let mut srv = TestServer::start().await;
    let mut c = srv.client().await;
    // Same name twice → my-api, my-api-2.
    let a = c
        .call(
            "workspace.create",
            json!({"name": "My API!!", "cwd": "/tmp"}),
        )
        .await
        .unwrap();
    let b = c
        .call(
            "workspace.create",
            json!({"name": "My API!!", "cwd": "/tmp"}),
        )
        .await
        .unwrap();
    assert_eq!(a["workspace"]["handle"], "my-api");
    assert_eq!(b["workspace"]["handle"], "my-api-2");
    // Handle-or-id resolves everywhere an id does.
    let g = c
        .call("workspace.get", json!({"workspace_id": "my-api"}))
        .await
        .unwrap();
    assert_eq!(g["workspace"]["name"], "My API!!");
    c.call(
        "workspace.rename",
        json!({"workspace_id": "my-api", "name": "Renamed"}),
    )
    .await
    .unwrap();
    let g = c
        .call("workspace.get", json!({"workspace_id": "my-api"}))
        .await
        .unwrap();
    assert_eq!(g["workspace"]["name"], "Renamed");
    assert_eq!(g["workspace"]["handle"], "my-api", "handles are immutable");
    c.call(
        "tab.create",
        json!({"workspace_id": "my-api-2", "title": "t"}),
    )
    .await
    .unwrap();
    c.call("workspace.refresh_git", json!({"workspace_id": "my-api-2"}))
        .await
        .unwrap();
    let err = c
        .call("workspace.get", json!({"workspace_id": "nope"}))
        .await
        .unwrap_err();
    assert!(err.contains("NO_SUCH_WORKSPACE"), "{err}");
    // Legacy migration: strip handles from the snapshot, restart, backfilled.
    drop(c);
    srv.restart().await;
    let snap_path = srv.state_dir.join("snapshot.json");
    // Graceful shutdown writes the snapshot; force a legacy shape by
    // removing every handle, then restart once more.
    let raw = std::fs::read_to_string(&snap_path).unwrap();
    let mut snap: serde_json::Value = serde_json::from_str(&raw).unwrap();
    for ws in snap["workspaces"].as_array_mut().unwrap() {
        ws.as_object_mut().unwrap().remove("handle");
    }
    std::fs::write(&snap_path, serde_json::to_string(&snap).unwrap()).unwrap();
    srv.restart().await;
    let mut c = srv.client().await;
    let l = c.call("workspace.list", json!({})).await.unwrap();
    let handles: Vec<String> = l["workspaces"]
        .as_array()
        .unwrap()
        .iter()
        .map(|w| w["handle"].as_str().unwrap_or("").to_string())
        .collect();
    assert_eq!(handles.len(), 2);
    assert!(handles.iter().all(|h| !h.is_empty()), "{handles:?}");
    assert!(handles[0] != handles[1], "unique: {handles:?}");
    // Migrated handles resolve too.
    let g = c
        .call("workspace.get", json!({"workspace_id": handles[0].clone()}))
        .await
        .unwrap();
    assert_eq!(g["workspace"]["handle"], handles[0]);
    srv.shutdown().await;
}

#[tokio::test]
async fn audit_replays_across_restart() {
    let mut srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let before = c.call("server.status", json!({})).await.unwrap()["seq"]
        .as_u64()
        .unwrap();
    c.call("notify", json!({"pane_id": pane, "title": "audit-me"}))
        .await
        .unwrap();
    drop(c);
    srv.restart().await;
    // The ring is empty after restart; the audit still serves the replay
    // for a pre-restart seq.
    let mut c2 = srv.client().await;
    c2.call("subscribe", json!({"events": ["*"], "from_seq": before}))
        .await
        .unwrap();
    let replayed = c2.read_events(2, Duration::from_secs(10)).await;
    let titles: Vec<&str> = replayed
        .iter()
        .filter(|e| e["event"] == "notification.created")
        .filter_map(|e| e["payload"]["notification"]["title"].as_str())
        .collect();
    assert_eq!(titles, vec!["audit-me"]);
    srv.shutdown().await;
}

#[tokio::test]
async fn new_events_after_restart_advance_the_previous_cursor() {
    let mut srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    c.call(
        "notify",
        json!({"pane_id": pane, "title": "before-restart"}),
    )
    .await
    .unwrap();
    let before = c.call("server.status", json!({})).await.unwrap()["seq"]
        .as_u64()
        .unwrap();
    drop(c);
    srv.restart().await;
    let mut c = srv.client().await;
    c.call("notify", json!({"pane_id": pane, "title": "after-restart"}))
        .await
        .unwrap();
    let after = c.call("server.status", json!({})).await.unwrap()["seq"]
        .as_u64()
        .unwrap();
    // Clean up the owned server even if the regression fails.
    let mut sub = srv.client().await;
    sub.call(
        "subscribe",
        json!({"events":["notification.created"], "from_seq":before}),
    )
    .await
    .unwrap();
    let replay = tokio::time::timeout(
        Duration::from_secs(1),
        sub.read_events(1, Duration::from_secs(2)),
    )
    .await;
    srv.shutdown().await;
    assert!(
        after > before,
        "new cursor {after} must advance old cursor {before}"
    );
    let replay = replay.expect("new event must replay after the old cursor");
    assert_eq!(
        replay[0]["payload"]["notification"]["title"],
        "after-restart"
    );
}

#[tokio::test]
async fn workspace_diff_reports_numstat_and_rejects_non_repo() {
    let dir = std::env::temp_dir().join(format!(
        "signaltty-intdiff-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    // The temp root is the git ceiling so a TMPDIR inside a checkout is never
    // discovered as a parent repo; passed explicitly, never via set_var.
    let ceiling = std::env::temp_dir();
    let run = |args: &[&str]| {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(&dir)
            .env("GIT_CEILING_DIRECTORIES", &ceiling)
            .args(args)
            .output()
            .unwrap();
        assert!(out.status.success(), "{args:?}");
    };
    run(&["init", "-q"]);
    run(&["config", "user.email", "t@t"]);
    run(&["config", "user.name", "t"]);
    run(&["config", "commit.gpgsign", "false"]);
    std::fs::write(dir.join("a.txt"), "1\n").unwrap();
    run(&["add", "."]);
    run(&["commit", "-qm", "base"]);
    std::fs::write(dir.join("a.txt"), "1\n2\n").unwrap();
    std::fs::write(dir.join("new.txt"), "u\n").unwrap();

    let srv =
        TestServer::start_with_env(&[("GIT_CEILING_DIRECTORIES", ceiling.to_str().unwrap())]).await;
    let mut c = srv.client().await;
    let w = c
        .call(
            "workspace.create",
            json!({"name": "diff ws", "cwd": dir.to_str().unwrap()}),
        )
        .await
        .unwrap();
    // Handle-or-id resolves for diff too.
    let d = c
        .call("workspace.diff", json!({"workspace_id": "diff-ws"}))
        .await
        .unwrap();
    assert_eq!(d["workspace_id"], w["workspace"]["id"]);
    let files = d["files"].as_array().unwrap();
    let a = files.iter().find(|f| f["path"] == "a.txt").unwrap();
    assert_eq!(
        (a["added"].as_u64(), a["removed"].as_u64()),
        (Some(1), Some(0))
    );
    let new = files.iter().find(|f| f["path"] == "new.txt").unwrap();
    assert_eq!(new["untracked"], true);
    assert_eq!(d["added"], 1);
    // Non-repo workspace is a loud error, never an empty lie. A sibling
    // dir, with the temp root as git ceiling so a TMPDIR inside a checkout
    // is never discovered as a parent repo.
    let bare = dir.with_extension("bare");
    std::fs::create_dir_all(&bare).unwrap();
    let w2 = c
        .call("workspace.create", json!({"cwd": bare.to_str().unwrap()}))
        .await
        .unwrap();
    let err = c
        .call(
            "workspace.diff",
            json!({"workspace_id": w2["workspace"]["id"]}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("BAD_PARAMS"), "{err}");
    srv.shutdown().await;
    std::fs::remove_dir_all(&dir).ok();
    std::fs::remove_dir_all(&bare).ok();
}

#[tokio::test]
async fn hook_event_drives_codex_lifecycle() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;

    // SessionStart → idle, records session id + resume argv.
    let r = c
        .call(
            "hook-event",
            json!({
                "agent": "codex", "event": "SessionStart", "pane_id": pane,
                "payload": {"session_id": "sess-1", "cwd": "/tmp"}
            }),
        )
        .await
        .unwrap();
    assert_eq!(r["lifecycle"], "idle");
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["agent"]["agent_session_id"], "sess-1");
    assert_eq!(p["pane"]["agent"]["kind"], "codex");
    assert_eq!(
        p["pane"]["agent"]["resume_argv"],
        json!(["codex", "resume", "sess-1"])
    );

    // Prompt → working; permission → blocked + permission_required.
    c.call(
        "hook-event",
        json!({"agent": "codex", "event": "UserPromptSubmit", "pane_id": pane}),
    )
    .await
    .unwrap();
    let r = c
        .call(
            "hook-event",
            json!({"agent": "codex", "event": "PermissionRequest", "pane_id": pane}),
        )
        .await
        .unwrap();
    assert_eq!(r["lifecycle"], "blocked");
    assert_eq!(r["attention"], "permission_required");
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert!(p["pane"]["last_message"]
        .as_str()
        .unwrap()
        .contains("approval"));

    // User replies → working again, attention cleared.
    let r = c
        .call(
            "hook-event",
            json!({"agent": "codex", "event": "UserPromptSubmit", "pane_id": pane}),
        )
        .await
        .unwrap();
    assert_eq!(r["lifecycle"], "working");
    assert_eq!(r["attention"], "none");

    // Stop → done + unread.
    let r = c
        .call("hook-event", json!({"agent": "codex", "event": "Stop", "pane_id": pane, "payload": {"session_id": "sess-1"}}))
        .await
        .unwrap();
    assert_eq!(r["lifecycle"], "done");
    assert_eq!(r["attention"], "unread");

    // Unknown agent rejected; unknown hook accepted as no-op.
    let err = c
        .call(
            "hook-event",
            json!({"agent": "nope", "event": "Stop", "pane_id": pane}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("unknown agent"), "{err}");
    assert!(c
        .call(
            "hook-event",
            json!({"agent": "codex", "event": "FutureEvent", "pane_id": pane})
        )
        .await
        .is_ok());
    srv.shutdown().await;
}

#[tokio::test]
async fn untitled_hook_message_is_the_last_message_verbatim() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    c.call(
        "hook-event",
        json!({"agent": "claude", "event": "PreToolUse", "pane_id": pane,
               "message": "Bash(cargo test)"}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    // The notification still needs a title; the pane's headline doesn't.
    assert_eq!(p["pane"]["last_message"], "Bash(cargo test)");

    c.call(
        "hook-event",
        json!({"agent": "claude", "event": "PreToolUse", "pane_id": pane,
               "title": "Tests", "message": "3 failed"}),
    )
    .await
    .unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["last_message"], "Tests: 3 failed");
    srv.shutdown().await;
}

#[tokio::test]
async fn hook_event_claude_notification_and_cursor_stop() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let r = c
        .call("hook-event", json!({
            "agent": "claude", "event": "Notification", "pane_id": pane,
            "payload": {"notification_type": "idle_prompt", "message": "shall I?", "session_id": "cs-1"}
        }))
        .await
        .unwrap();
    assert_eq!(r["lifecycle"], "blocked");
    assert_eq!(r["attention"], "input_required");
    let r = c
        .call(
            "hook-event",
            json!({
                "agent": "cursor", "event": "stop", "pane_id": pane,
                "payload": {"session_id": "chat-9"}
            }),
        )
        .await
        .unwrap();
    assert_eq!(r["lifecycle"], "done");
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(
        p["pane"]["agent"]["resume_argv"],
        json!(["cursor-agent", "--resume", "chat-9"])
    );
    srv.shutdown().await;
}

fn codex_fixture(dir: &std::path::Path) -> std::path::PathBuf {
    use std::os::unix::fs::PermissionsExt;
    std::fs::create_dir_all(dir).unwrap();
    let path = dir.join("codex");
    std::fs::write(
        &path,
        "#!/bin/sh\nif [ \"$1\" = --no-daemon ]; then echo fixture-local-runtime; shift; fi\ncase \"$1\" in\n--version) echo fixture-codex ;;\nresume) printf 'fixture-resume:%s\\n' \"$2\"; read line ;;\n*) exit 1 ;;\nesac\n",
    )
    .unwrap();
    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
    path
}

#[tokio::test]
async fn spawn_detects_agent_kind() {
    let srv = TestServer::start().await;
    let codex = codex_fixture(srv.socket.parent().unwrap());
    let mut c = srv.client().await;
    let w = c
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    let ws = w["workspace"]["id"].as_str().unwrap();
    // Fixture basename drives the same detection as an installed agent.
    let t = c
        .call("tab.create", json!({"workspace_id": ws, "title": "t"}))
        .await
        .unwrap();
    let tab = t["tab"]["id"].as_str().unwrap();
    let p = c
        .call(
            "pane.spawn",
            json!({"workspace_id": ws, "tab_id": tab, "argv": [codex, "--version"]}),
        )
        .await
        .unwrap();
    assert_eq!(p["pane"]["agent"]["kind"], "codex");
    // Plain shell → generic.
    let t = c
        .call("tab.create", json!({"workspace_id": ws, "title": "t2"}))
        .await
        .unwrap();
    let p = c
        .call(
            "pane.spawn",
            json!({"workspace_id": ws, "tab_id": t["tab"]["id"], "argv": ["sh"]}),
        )
        .await
        .unwrap();
    assert_eq!(p["pane"]["agent"]["kind"], "generic");
    // Explicit hint wins over detection.
    let t = c
        .call("tab.create", json!({"workspace_id": ws, "title": "t3"}))
        .await
        .unwrap();
    let p = c
        .call("pane.spawn", json!({"workspace_id": ws, "tab_id": t["tab"]["id"], "argv": ["sh"], "agent_hint": "claude"}))
        .await
        .unwrap();
    assert_eq!(p["pane"]["agent"]["kind"], "claude");
    srv.shutdown().await;
}

#[tokio::test]
async fn report_session_builds_resume_and_pane_resume_spawns() {
    let agents = plugin_test_dir("codex-resume");
    let codex = codex_fixture(&agents);
    write_manifest(
        &agents,
        "codex.toml",
        &format!(
            "[agent]\nkind = \"codex\"\n[session]\nresume = [{}, \"resume\", \"{{session_id}}\"]\n",
            json!(codex)
        ),
    );
    let mut srv = TestServer::start_with_dirs(None, Some(&agents)).await;
    let mut c = srv.client().await;
    let custom = srv.integration_home.join("custom-codex");
    let ws = c
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    let launch = c
        .call(
            "pane.spawn",
            json!({"workspace_id": ws["workspace"]["id"], "argv": [codex, "--version"],
        "env": {"CODEX_HOME": custom, "CODEX_API_KEY": "must-not-persist"}}),
        )
        .await
        .unwrap();
    let pane = launch["pane"]["id"].as_str().unwrap().to_string();
    assert_eq!(
        launch["pane"]["agent"]["config_env"],
        json!({"CODEX_HOME": custom})
    );
    c.call(
        "wait",
        json!({"pane_id": pane, "until": "exited", "timeout_s": 15}),
    )
    .await
    .unwrap();
    let r = c
        .call(
            "report-session",
            json!({"pane_id": pane, "agent_session_id": "bogus-id", "agent": "codex"}),
        )
        .await
        .unwrap();
    assert_eq!(
        r["pane"]["agent"]["resume_argv"],
        json!([codex, "resume", "bogus-id"])
    );
    srv.restart().await;
    c = srv.client().await;
    assert!(
        !std::fs::read_to_string(srv.state_dir.join("snapshot.json"))
            .unwrap()
            .contains("must-not-persist")
    );
    let hook_file = custom.join("hooks.json");
    assert!(hook_file.is_file());
    std::fs::remove_file(&hook_file).unwrap();
    // Resume launches the fixture with the reported session, on a real PTY.
    let r = c
        .call("pane.resume", json!({"pane_id": pane}))
        .await
        .unwrap();
    assert_eq!(r["pane"]["live"]["state"], "live");
    assert_eq!(r["pane"]["restore_state"], "LIVE");
    assert_eq!(r["integration"]["status"], "configured");
    assert_eq!(r["integration"]["changed"], true);
    assert!(hook_file.is_file());
    assert_eq!(r["pane"]["title"], "codex");
    wait_for_text(
        &mut c,
        &pane,
        "fixture-resume:bogus-id",
        Duration::from_secs(5),
    )
    .await;
    wait_for_text(
        &mut c,
        &pane,
        "fixture-local-runtime",
        Duration::from_secs(5),
    )
    .await;
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["live"]["state"], "live");
    c.call("pane.close", json!({"pane_id": pane}))
        .await
        .unwrap();
    // Resuming a live pane is rejected.
    let (_ws2, pane2) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let err = c
        .call("pane.resume", json!({"pane_id": pane2}))
        .await
        .unwrap_err();
    assert!(err.contains("already live"), "{err}");
    srv.shutdown().await;
    std::fs::remove_dir_all(agents).unwrap();
}

#[tokio::test]
async fn baseline_wait_ignores_old_done_and_accepts_fast_new_work() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    c.call(
        "hook-event",
        json!({"agent":"codex", "event":"Stop", "pane_id":pane}),
    )
    .await
    .unwrap();
    let old = c.call("pane.get", json!({"pane_id":pane})).await.unwrap();
    let baseline = old["wait_baseline"].clone();
    if !baseline.is_object() {
        srv.shutdown().await;
        panic!("pane.get must expose a new-work baseline");
    }
    let stale = c
        .call(
            "wait",
            json!({"pane_id":pane, "until":"done", "after":baseline, "timeout_s":0}),
        )
        .await;
    assert!(stale.unwrap_err().starts_with("TIMEOUT"));
    c.call(
        "hook-event",
        json!({"agent":"codex", "event":"UserPromptSubmit", "pane_id":pane}),
    )
    .await
    .unwrap();
    c.call(
        "hook-event",
        json!({"agent":"codex", "event":"Stop", "pane_id":pane}),
    )
    .await
    .unwrap();
    let result = c.call("wait", json!({"pane_id":pane, "until":["done", "blocked", "failed"], "after":baseline, "timeout_s":1})).await.unwrap();
    assert_eq!(result["outcome"], "done");
    assert!(
        result["transition_seq"].as_u64().unwrap() > baseline["lifecycle_seq"].as_u64().unwrap()
    );
    let current = c
        .call(
            "wait",
            json!({"pane_id":pane, "until":"done", "timeout_s":0}),
        )
        .await
        .unwrap();
    assert_eq!(current["satisfied"], true);
    let output = tokio::process::Command::new(signaltty_testkit::bin_path("signaltty"))
        .arg("--socket")
        .arg(&srv.socket)
        .args(["--json", "wait", "--pane"])
        .arg(&pane)
        .args(["--until", "done,blocked,failed", "--after-baseline"])
        .arg(baseline.to_string())
        .args(["--timeout", "0"])
        .output()
        .await
        .unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap()["outcome"],
        "done"
    );
    srv.shutdown().await;
}

#[tokio::test]
async fn baseline_wait_remembers_brief_matching_transitions() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    c.call(
        "hook-event",
        json!({"pane_id":pane,"agent":"codex","event":"Stop"}),
    )
    .await
    .unwrap();
    let baseline =
        c.call("pane.get", json!({"pane_id":pane})).await.unwrap()["wait_baseline"].clone();
    for event in ["UserPromptSubmit", "PermissionRequest", "UserPromptSubmit"] {
        c.call(
            "hook-event",
            json!({"pane_id":pane,"agent":"codex","event":event}),
        )
        .await
        .unwrap();
    }
    let result = c
        .call(
            "wait",
            json!({"pane_id":pane,"until":["done","blocked"],"after":baseline,"timeout_s":0}),
        )
        .await
        .unwrap();
    assert_eq!(result["outcome"], "blocked");
    assert_eq!(
        result["lifecycle"], "working",
        "current state is still reported separately"
    );
    assert!(c
        .call(
            "wait",
            json!({"pane_id":pane,"until":"blocked","timeout_s":0})
        )
        .await
        .unwrap_err()
        .starts_with("TIMEOUT"));
    srv.shutdown().await;
}

#[tokio::test]
async fn baseline_wait_tracks_session_discovery_replacement_and_restart() {
    let mut srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let initial =
        c.call("pane.get", json!({"pane_id":pane})).await.unwrap()["wait_baseline"].clone();
    c.call(
        "report-session",
        json!({"pane_id":pane,"agent":"codex","agent_session_id":"first"}),
    )
    .await
    .unwrap();
    c.call(
        "hook-event",
        json!({"pane_id":pane,"agent":"codex","event":"PermissionRequest"}),
    )
    .await
    .unwrap();
    let blocked = c.call("wait",json!({"pane_id":pane,"until":["done","blocked","failed"],"after":initial,"timeout_s":1})).await.unwrap();
    assert_eq!(blocked["outcome"], "blocked");
    let known = c.call("pane.get", json!({"pane_id":pane})).await.unwrap()["wait_baseline"].clone();
    for session in ["second", "first"] {
        c.call(
            "report-session",
            json!({"pane_id":pane,"agent":"codex","agent_session_id":session}),
        )
        .await
        .unwrap();
    }
    let replaced = c
        .call(
            "wait",
            json!({"pane_id":pane,"until":"blocked","after":known,"timeout_s":0}),
        )
        .await
        .unwrap_err();
    assert!(replaced.starts_with("IDENTITY_CHANGED"), "{replaced}");
    for params in [
        json!({"pane_id":pane,"until":[],"after":initial}),
        json!({"pane_id":pane,"until":"done","after":true}),
        json!({"pane_id":pane,"until":["done",7]}),
    ] {
        assert!(c
            .call("wait", params)
            .await
            .unwrap_err()
            .starts_with("BAD_PARAMS"));
    }
    let current =
        c.call("pane.get", json!({"pane_id":pane})).await.unwrap()["wait_baseline"].clone();
    srv.restart().await;
    let mut c = srv.client().await;
    let stale = c
        .call(
            "wait",
            json!({"pane_id":pane,"until":"exited","after":current,"timeout_s":0}),
        )
        .await
        .unwrap_err();
    assert!(stale.starts_with("IDENTITY_CHANGED"), "{stale}");
    srv.shutdown().await;
}

#[tokio::test]
async fn subscribe_declares_corrupt_history_and_cursor_ahead() {
    let mut srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    c.call("notify", json!({"pane_id":pane,"title":"before"}))
        .await
        .unwrap();
    let mut ahead = srv.client().await;
    let result = ahead
        .call("subscribe", json!({"from_seq":u64::MAX}))
        .await
        .unwrap();
    assert_eq!(result["subscribed"], false);
    assert_eq!(result["replay"]["status"], "cursor_ahead");
    srv.restart().await;
    use std::io::Write;
    std::fs::OpenOptions::new()
        .append(true)
        .open(srv.state_dir.join("audit.jsonl"))
        .unwrap()
        .write_all(b"{broken\n")
        .unwrap();
    let mut sub = srv.client().await;
    let result = sub.call("subscribe", json!({"from_seq":0})).await.unwrap();
    assert_eq!(result["subscribed"], false);
    assert_eq!(result["replay"]["status"], "unavailable");
    assert_eq!(result["replay"]["returned"], 0);
    assert_eq!(result["replay"]["recovery"], "snapshot_then_resubscribe");
    assert!(
        sub.call("server.status", json!({})).await.is_err(),
        "incomplete streams close after acknowledgment"
    );
    srv.shutdown().await;
}

#[tokio::test]
async fn wait_until_blocked_via_hook() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let pane2 = pane.clone();
    let sock = srv.socket.clone();
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        let mut c = TestClient::connect(&sock).await.unwrap();
        c.call(
            "hook-event",
            json!({"agent": "codex", "event": "PermissionRequest", "pane_id": pane2}),
        )
        .await
        .unwrap();
    });
    let w = c
        .call(
            "wait",
            json!({"pane_id": pane, "until": "blocked", "timeout_s": 10}),
        )
        .await
        .unwrap();
    assert_eq!(w["satisfied"], true);
    assert_eq!(w["lifecycle"], "blocked");
    srv.shutdown().await;
}

#[tokio::test]
async fn wait_timeout_and_seen() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let err = c
        .call(
            "wait",
            json!({"pane_id": pane, "until": "exited", "timeout_s": 1}),
        )
        .await
        .unwrap_err();
    assert!(err.starts_with("TIMEOUT"), "{err}");
    // Attention wait: notify in background, wait for unread.
    let pane2 = pane.clone();
    let sock = srv.socket.clone();
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        let mut c = TestClient::connect(&sock).await.unwrap();
        c.call("notify", json!({"pane_id": pane2, "title": "hi"}))
            .await
            .unwrap();
    });
    let w = c
        .call(
            "wait",
            json!({"pane_id": pane, "until": "unread", "timeout_s": 5}),
        )
        .await
        .unwrap();
    assert_eq!(w["satisfied"], true);
    srv.shutdown().await;
}

// ---- Phase 4: executable plugins ----

fn plugin_test_dir(tag: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!(
        "signaltty-plugtest-{}-{}-{}",
        std::process::id(),
        tag,
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .subsec_nanos()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

fn write_recorder_plugin(dir: &std::path::Path, log: &std::path::Path) {
    let sub = dir.join("recorder");
    std::fs::create_dir_all(&sub).unwrap();
    std::fs::write(
        sub.join("plugin.toml"),
        format!(
            "[plugin]\nname = \"recorder\"\nversion = \"0.1.0\"\n\n\
             [[hook]]\nevents = [\"notification.created\", \"attention.*\"]\n\
             command = [\"./record.sh\"]\n\n\
             [hook.env]\nHOOK_LOG = \"{}\"\n",
            log.display()
        ),
    )
    .unwrap();
    std::fs::write(
        sub.join("record.sh"),
        "#!/bin/sh\ncat >> \"$HOOK_LOG\" <<EOF\n$SIGNALTTY_EVENT :: $(cat)\nEOF\n",
    )
    .unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(
            sub.join("record.sh"),
            std::fs::Permissions::from_mode(0o755),
        )
        .unwrap();
    }
}

#[tokio::test]
async fn plugin_hook_fires_on_attention() {
    let base = plugin_test_dir("hook");
    let log = base.join("events.log");
    write_recorder_plugin(&base.join("plugins"), &log);
    let srv = TestServer::start_with_plugin_dir(Some(&base.join("plugins"))).await;
    let mut c = srv.client().await;

    let list = c.call("plugin.list", json!({})).await.unwrap();
    assert_eq!(list["plugins"].as_array().unwrap().len(), 1);
    assert_eq!(list["plugins"][0]["name"], "recorder");

    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    c.call("notify", json!({"pane_id": pane, "title": "plug-check"}))
        .await
        .unwrap();

    let start = std::time::Instant::now();
    loop {
        let body = std::fs::read_to_string(&log).unwrap_or_default();
        if body.contains("notification.created") && body.contains("attention.created") {
            // Envelope on stdin: pane id of the triggering pane.
            assert!(body.contains(&pane), "hook stdin must carry payload");
            break;
        }
        assert!(
            start.elapsed() < Duration::from_secs(10),
            "hook did not fire"
        );
        tokio::time::sleep(Duration::from_millis(50)).await;
    }

    // Stats land just after hook exit; poll briefly.
    let start = std::time::Instant::now();
    loop {
        let list = c.call("plugin.list", json!({})).await.unwrap();
        let hook = &list["plugins"][0]["hooks"][0];
        if hook["runs"].as_u64().unwrap_or(0) >= 2 {
            assert_eq!(hook["errors"], 0, "{hook}");
            break;
        }
        assert!(
            start.elapsed() < Duration::from_secs(5),
            "stats lag: {hook}"
        );
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    srv.shutdown().await;
    std::fs::remove_dir_all(&base).ok();
}

#[tokio::test]
async fn plugin_reload_and_failure_isolation() {
    let base = plugin_test_dir("reload");
    let plugdir = base.join("plugins");
    write_recorder_plugin(&plugdir, &base.join("events.log"));
    let srv = TestServer::start_with_plugin_dir(Some(&plugdir)).await;
    let mut c = srv.client().await;

    let list = c.call("plugin.list", json!({})).await.unwrap();
    assert_eq!(list["plugins"].as_array().unwrap().len(), 1);
    assert!(list["failures"].as_array().unwrap().is_empty());

    // Add a second good plugin and one broken manifest, then reload.
    let sub = plugdir.join("second");
    std::fs::create_dir_all(&sub).unwrap();
    std::fs::write(sub.join("plugin.toml"), "[plugin]\nname = \"second\"\n").unwrap();
    let broken = plugdir.join("broken");
    std::fs::create_dir_all(&broken).unwrap();
    std::fs::write(broken.join("plugin.toml"), "this is [[[ not toml\n").unwrap();

    let list = c.call("plugin.reload", json!({})).await.unwrap();
    let names: Vec<String> = list["plugins"]
        .as_array()
        .unwrap()
        .iter()
        .map(|p| p["name"].as_str().unwrap().to_string())
        .collect();
    assert_eq!(names, vec!["recorder".to_string(), "second".to_string()]);
    let failures = list["failures"].as_array().unwrap();
    assert_eq!(failures.len(), 1);
    assert_eq!(failures[0]["dir"], "broken");
    srv.shutdown().await;
    std::fs::remove_dir_all(&base).ok();
}

fn ratio_at(layout: &serde_json::Value, path: &[usize]) -> f64 {
    let mut node = layout;
    for side in path {
        node = &node[if *side == 0 { "first" } else { "second" }];
    }
    node["ratio"].as_f64().unwrap()
}

#[tokio::test]
async fn set_ratio_persists_divider_and_clamps() {
    let mut srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let s = c
        .call(
            "pane.split",
            json!({"pane_id": pane, "direction": "right", "argv": ["sleep", "30"]}),
        )
        .await
        .unwrap();
    let pane2 = s["pane"]["id"].as_str().unwrap().to_string();
    let pane2_tab = s["pane"]["tab_id"].as_str().unwrap().to_string();
    c.call(
        "pane.split",
        json!({"pane_id": pane2, "direction": "right", "argv": ["sleep", "30"]}),
    )
    .await
    .unwrap();
    let layout = c
        .call("workspace.get", json!({"workspace_id": ws}))
        .await
        .unwrap()["tabs"][0]["layout"]
        .clone();
    // Equal thirds through the whole stack, not 1/2 + 1/4 + 1/4.
    assert!((ratio_at(&layout, &[]) - 1.0 / 3.0).abs() < 1e-6);
    assert!((ratio_at(&layout, &[1]) - 0.5).abs() < 1e-6);

    // A dragged divider persists.
    let r = c
        .call(
            "tab.set_ratio",
            json!({"tab_id": pane2_tab, "path": [], "ratio": 0.25}),
        )
        .await
        .unwrap();
    assert!((ratio_at(&r["tab"]["layout"], &[]) - 0.25).abs() < 1e-6);
    let g = c
        .call("workspace.get", json!({"workspace_id": ws}))
        .await
        .unwrap();
    assert!((ratio_at(&g["tabs"][0]["layout"], &[]) - 0.25).abs() < 1e-6);

    // Out-of-range ratios clamp instead of collapsing a pane.
    c.call(
        "tab.set_ratio",
        json!({"tab_id": pane2_tab, "path": [], "ratio": 0.99}),
    )
    .await
    .unwrap();
    let g = c
        .call("workspace.get", json!({"workspace_id": ws}))
        .await
        .unwrap();
    assert!((ratio_at(&g["tabs"][0]["layout"], &[]) - 0.95).abs() < 1e-6);

    // Bad paths and unknown tabs fail without touching the layout.
    assert!(c
        .call(
            "tab.set_ratio",
            json!({"tab_id": pane2_tab, "path": [1, 1, 1], "ratio": 0.5}),
        )
        .await
        .is_err());
    assert!(c
        .call(
            "tab.set_ratio",
            json!({"tab_id": pane2_tab, "path": [2], "ratio": 0.5}),
        )
        .await
        .is_err());
    assert!(c
        .call(
            "tab.set_ratio",
            json!({"tab_id": "tab_nope", "path": [], "ratio": 0.5}),
        )
        .await
        .is_err());

    // The divider survives a server restart.
    srv.restart().await;
    let mut c = srv.client().await;
    let g = c
        .call("workspace.get", json!({"workspace_id": ws}))
        .await
        .unwrap();
    assert!((ratio_at(&g["tabs"][0]["layout"], &[]) - 0.95).abs() < 1e-6);
    srv.shutdown().await;
}

#[tokio::test]
async fn schema_lists_every_dispatched_method() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let schema = c.call("server.schema", json!({})).await.unwrap();
    assert_eq!(schema["protocol"], "signaltty/1");
    assert!(!schema["version"].as_str().unwrap_or("").is_empty());
    let methods: Vec<String> = serde_json::from_value(schema["methods"].clone()).unwrap();
    for must in [
        "server.status",
        "server.schema",
        "pane.spawn",
        "hook-event",
        "wait",
        "focus.next_unread",
    ] {
        assert!(methods.contains(&must.to_string()), "{must} listed");
    }
    assert!(!schema["events"].as_array().unwrap().is_empty());
    assert!(!schema["codes"].as_array().unwrap().is_empty());
    // Every listed method dispatches: probing with {} may fail param
    // decode or id lookup, but never falls through to UNKNOWN_METHOD.
    for m in &methods {
        if m == "server.shutdown" {
            continue; // would stop the fixture; covered by shutdown tests
        }
        match c.call(m, json!({})).await {
            Ok(_) => {}
            Err(e) => assert!(
                !e.starts_with("UNKNOWN_METHOD"),
                "{m} listed but not dispatched: {e}"
            ),
        }
    }
    srv.shutdown().await;
}

#[tokio::test]
async fn spawn_rejects_a_tab_in_another_workspace() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let a = c
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    let b = c
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    let tab = c
        .call("tab.create", json!({"workspace_id": b["workspace"]["id"]}))
        .await
        .unwrap();
    let err = c.call("pane.spawn", json!({"workspace_id": a["workspace"]["id"], "tab_id": tab["tab"]["id"], "argv": ["sleep", "30"]})).await.unwrap_err();
    assert!(err.contains("BAD_PARAMS"), "{err}");
    assert_eq!(
        c.call("server.status", json!({})).await.unwrap()["live_panes"],
        0
    );
    srv.shutdown().await;
}

#[tokio::test]
async fn failed_spawn_does_not_create_an_automatic_tab() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let ws = c
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap()["workspace"]["id"]
        .clone();
    let before = c
        .call("workspace.get", json!({"workspace_id": ws}))
        .await
        .unwrap();
    let err = c
        .call(
            "pane.spawn",
            json!({"workspace_id": ws, "argv": ["/nonexistent/signaltty-test-command"]}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("SPAWN_FAILED"), "{err}");
    let after = c
        .call("workspace.get", json!({"workspace_id": ws}))
        .await
        .unwrap();
    assert_eq!(after, before);
    assert_eq!(
        c.call("server.status", json!({})).await.unwrap()["live_panes"],
        0
    );
    srv.shutdown().await;
}

#[tokio::test]
async fn layout_replacement_cannot_hide_owned_panes() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    c.call(
        "pane.split",
        json!({"pane_id": pane, "direction": "right", "argv": ["sleep", "30"]}),
    )
    .await
    .unwrap();
    let before = c
        .call("workspace.get", json!({"workspace_id": ws}))
        .await
        .unwrap();
    let tab = before["tabs"][0]["id"].clone();
    let err = c
        .call(
            "tab.set_layout",
            json!({"tab_id": tab, "layout": {"type": "pane", "pane_id": pane}}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("BAD_PARAMS"), "{err}");
    assert_eq!(
        c.call("workspace.get", json!({"workspace_id": ws}))
            .await
            .unwrap()["tabs"],
        before["tabs"]
    );
    c.call("workspace.close", json!({"workspace_id": ws}))
        .await
        .unwrap();
    assert_eq!(
        c.call("server.status", json!({})).await.unwrap()["live_panes"],
        0
    );
    srv.shutdown().await;
}

#[tokio::test]
async fn layout_rejects_duplicate_panes_and_clamps_ratios() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let tab = c
        .call("workspace.get", json!({"workspace_id": ws}))
        .await
        .unwrap()["tabs"][0]["id"]
        .clone();
    let duplicate = json!({"type": "split", "dir": "right", "ratio": 0.5, "first": {"type": "pane", "pane_id": pane}, "second": {"type": "pane", "pane_id": pane}});
    let err = c
        .call(
            "tab.set_layout",
            json!({"tab_id": tab, "layout": duplicate}),
        )
        .await
        .unwrap_err();
    assert!(err.contains("BAD_PARAMS"), "{err}");
    let sibling = c
        .call(
            "pane.split",
            json!({"pane_id": pane, "direction": "right", "argv": ["sleep", "30"]}),
        )
        .await
        .unwrap()["pane"]["id"]
        .clone();
    let layout = json!({"type": "split", "dir": "right", "ratio": 3.0, "first": {"type": "pane", "pane_id": pane}, "second": {"type": "pane", "pane_id": sibling}});
    let result = c
        .call("tab.set_layout", json!({"tab_id": tab, "layout": layout}))
        .await
        .unwrap();
    assert!((result["tab"]["layout"]["ratio"].as_f64().unwrap() - 0.95).abs() < 1e-6);
    srv.shutdown().await;
}

#[tokio::test]
async fn detach_stops_streaming_without_stopping_the_process() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let mut control = srv.client().await;
    let (_ws, pane) = new_pane(
        &mut control,
        vec!["sh", "-c", "while read line; do echo RESULT:$line; done"],
    )
    .await;
    c.call("pane.attach", json!({"pane_id": pane, "mark_seen": false}))
        .await
        .unwrap();
    c.call("pane.detach", json!({"pane_id": pane}))
        .await
        .unwrap();
    control
        .call(
            "pane.input",
            json!({"pane_id": pane, "data_b64": base64_encode("detached-marker\n")}),
        )
        .await
        .unwrap();
    wait_for_text(
        &mut control,
        &pane,
        "RESULT:detached-marker",
        Duration::from_secs(5),
    )
    .await;
    let line = c.raw_roundtrip(&json!({"protocol": "signaltty/1", "id": "after-detach", "method": "pane.get", "params": {"pane_id": pane}}).to_string()).await;
    let response: serde_json::Value = serde_json::from_str(&line).unwrap();
    assert_eq!(
        response["id"], "after-detach",
        "unexpected stream after detach: {line}"
    );
    assert_eq!(response["result"]["pane"]["live"]["state"], "live");
    srv.shutdown().await;
}

#[tokio::test]
async fn attach_snapshot_and_stream_share_output_offsets() {
    let srv = TestServer::start().await;
    let mut control = srv.client().await;
    let mut attached = srv.client().await;
    let (_ws, pane) = new_pane(
        &mut control,
        vec![
            "sh",
            "-c",
            "echo before-snapshot; while read line; do echo RESULT:$line; done",
        ],
    )
    .await;
    wait_for_text(
        &mut control,
        &pane,
        "before-snapshot",
        Duration::from_secs(5),
    )
    .await;
    let snapshot = attached
        .call("pane.attach", json!({"pane_id": pane, "mark_seen": false}))
        .await
        .unwrap();
    let offset = snapshot["output_offset"]
        .as_u64()
        .expect("snapshot must identify covered output");
    assert!(offset > 0);
    control
        .call(
            "pane.input",
            json!({"pane_id": pane, "data_b64": base64_encode("offset-marker\n")}),
        )
        .await
        .unwrap();
    let events = attached.read_events(1, Duration::from_secs(5)).await;
    assert_eq!(events[0]["event"], "pty.data");
    assert!(events[0]["payload"]["output_offset"].as_u64().unwrap() > offset);
    wait_for_text(
        &mut control,
        &pane,
        "RESULT:offset-marker",
        Duration::from_secs(5),
    )
    .await;
    let next = control
        .call("pane.attach", json!({"pane_id": pane, "mark_seen": false}))
        .await
        .unwrap();
    assert!(
        next["output_offset"].as_u64().unwrap()
            >= events[0]["payload"]["output_offset"].as_u64().unwrap()
    );
    srv.shutdown().await;
}

#[tokio::test]
async fn concurrent_spawn_and_close_do_not_leave_a_live_pane() {
    let srv = TestServer::start().await;
    let mut spawning = srv.client().await;
    let mut closing = srv.client().await;
    for _ in 0..12 {
        let ws = spawning
            .call("workspace.create", json!({"cwd": "/tmp"}))
            .await
            .unwrap()["workspace"]["id"]
            .clone();
        let (spawned, closed) = tokio::join!(
            spawning.call(
                "pane.spawn",
                json!({"workspace_id": ws, "argv": ["sleep", "30"]})
            ),
            closing.call("workspace.close", json!({"workspace_id": ws})),
        );
        closed.unwrap();
        if let Err(error) = spawned {
            assert!(error.contains("NO_SUCH_WORKSPACE"), "{error}");
        }
        assert_eq!(
            closing.call("server.status", json!({})).await.unwrap()["live_panes"],
            0
        );
    }
    srv.shutdown().await;
}

#[tokio::test]
async fn closing_restored_legacy_layout_cleans_hidden_owned_panes() {
    for close_workspace in [false, true] {
        let mut srv = TestServer::start().await;
        let mut c = srv.client().await;
        let (ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
        let tab = c
            .call("workspace.get", json!({"workspace_id": ws}))
            .await
            .unwrap()["tabs"][0]["id"]
            .clone();
        c.call("server.shutdown", json!({"force": true}))
            .await
            .unwrap();
        drop(c);
        tokio::time::sleep(Duration::from_millis(250)).await;
        let path = srv.state_dir.join("snapshot.json");
        let mut snapshot: serde_json::Value =
            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
        snapshot["tabs"][0]["layout"] = serde_json::Value::Null;
        std::fs::write(&path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
        srv.restart().await;
        let mut c = srv.client().await;
        assert!(c.call("pane.get", json!({"pane_id": pane})).await.is_ok());
        if close_workspace {
            c.call("workspace.close", json!({"workspace_id": ws}))
                .await
                .unwrap();
        } else {
            c.call("tab.close", json!({"tab_id": tab})).await.unwrap();
        }
        let error = c
            .call("pane.get", json!({"pane_id": pane}))
            .await
            .unwrap_err();
        assert!(error.contains("NO_SUCH_PANE"), "{error}");
        srv.shutdown().await;
    }
}

#[tokio::test]
async fn attached_pane_streams_after_exit_and_resume_without_reattaching() {
    let agents = plugin_test_dir("resume-stream");
    write_manifest(
        &agents,
        "resume.toml",
        r#"
[agent]
kind = "codex"
binaries = ["sh"]
[session]
resume = ["sh", "-c", "echo resumed-stream; read line"]
"#,
    );
    let srv = TestServer::start_with_dirs(None, Some(&agents)).await;
    let mut control = srv.client().await;
    let mut attached = srv.client().await;
    let (_ws, pane) = new_pane(
        &mut control,
        vec!["sh", "-c", "read line; echo initial-stream"],
    )
    .await;
    control
        .call(
            "report-session",
            json!({"pane_id": pane, "agent": "codex", "agent_session_id": "fixture"}),
        )
        .await
        .unwrap();
    attached
        .call("pane.attach", json!({"pane_id": pane, "mark_seen": false}))
        .await
        .unwrap();
    control
        .call(
            "pane.input",
            json!({"pane_id": pane, "data_b64": base64_encode("exit\n")}),
        )
        .await
        .unwrap();
    control
        .call(
            "wait",
            json!({"pane_id": pane, "until": "exited", "timeout_s": 5}),
        )
        .await
        .unwrap();
    // Consume already queued output while confirming the initial child exited.
    let exited = attached
        .call("pane.get", json!({"pane_id": pane}))
        .await
        .unwrap();
    assert_eq!(exited["pane"]["live"]["state"], "exited");
    control
        .call("pane.resume", json!({"pane_id": pane}))
        .await
        .unwrap();
    let stale = control
        .call(
            "wait",
            json!({"pane_id":pane,"until":"unknown","after":exited["wait_baseline"],"timeout_s":0}),
        )
        .await
        .unwrap_err();
    assert!(stale.starts_with("IDENTITY_CHANGED"), "{stale}");
    let streamed = attached.read_events(1, Duration::from_secs(3)).await;
    assert_eq!(streamed[0]["event"], "pty.data");
    use base64::Engine;
    let bytes = base64::engine::general_purpose::STANDARD
        .decode(streamed[0]["payload"]["data_b64"].as_str().unwrap())
        .unwrap();
    assert!(String::from_utf8_lossy(&bytes).contains("resumed-stream"));
    srv.shutdown().await;
    std::fs::remove_dir_all(agents).ok();
}

#[tokio::test]
async fn simultaneous_resume_requests_launch_only_one_child() {
    let agents = plugin_test_dir("resume-race");
    write_manifest(
        &agents,
        "resume.toml",
        r#"
[agent]
kind = "codex"
binaries = ["sh"]
[session]
resume = ["sh", "-c", "read line"]
"#,
    );
    let srv = TestServer::start_with_dirs(None, Some(&agents)).await;
    let mut a = srv.client().await;
    let mut b = srv.client().await;
    for _ in 0..8 {
        let (_ws, pane) = new_pane(&mut a, vec!["sh", "-c", "exit 0"]).await;
        a.call(
            "wait",
            json!({"pane_id": pane, "until": "exited", "timeout_s": 5}),
        )
        .await
        .unwrap();
        a.call(
            "report-session",
            json!({"pane_id": pane, "agent": "codex", "agent_session_id": "fixture"}),
        )
        .await
        .unwrap();
        let (first, second) = tokio::join!(
            a.call("pane.resume", json!({"pane_id": pane})),
            b.call("pane.resume", json!({"pane_id": pane})),
        );
        assert_ne!(
            first.is_ok(),
            second.is_ok(),
            "exactly one resume must succeed: {first:?}, {second:?}"
        );
        let error = first.err().or_else(|| second.err()).unwrap();
        assert!(error.contains("already live"), "{error}");
        a.call("pane.close", json!({"pane_id": pane}))
            .await
            .unwrap();
    }
    srv.shutdown().await;
    std::fs::remove_dir_all(agents).ok();
}

fn lifecycle_fixture(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
    use std::os::unix::fs::PermissionsExt;
    let path = dir.join(name);
    std::fs::write(&path, r#"#!/usr/bin/env python3
import json, os, pathlib, subprocess, sys
root = pathlib.Path(os.environ.get('CLAUDE_CONFIG_DIR', str(pathlib.Path(os.environ['SIGNALTTY_INTEGRATION_HOME']) / '.claude')))
print('agent-started', flush=True)
try:
    hooks = json.loads((root / 'settings.json').read_text())['hooks']
except Exception:
    hooks = {}
print('hooks-loaded' if hooks else 'hooks-unavailable', flush=True)
for line in sys.stdin:
    event = line.strip()
    for group in hooks.get(event, []):
        for hook in group['hooks']:
            subprocess.run(hook['command'], shell=True, input=json.dumps({'session_id':'fixture', 'notification_type':'permission_prompt'}).encode(), check=True)
    print('event-delivered:' + event, flush=True)
"#).unwrap();
    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
    path
}

#[tokio::test]
async fn automatic_hooks_are_loaded_before_spawn_and_split_and_drive_states() {
    use base64::Engine;
    let srv = TestServer::start().await;
    let claude = lifecycle_fixture(srv.socket.parent().unwrap(), "claude");
    let file = srv.integration_home.join(".claude/settings.json");
    std::fs::create_dir_all(file.parent().unwrap()).unwrap();
    std::fs::write(
        &file,
        r#"{"model":"opus","hooks":{"Stop":[{"hooks":[{"type":"command","command":"true"}]}]}}"#,
    )
    .unwrap();
    let mut c = srv.client().await;
    let w = c
        .call("workspace.create", json!({"cwd":"/tmp"}))
        .await
        .unwrap();
    let result = c
        .call(
            "pane.spawn",
            json!({"workspace_id":w["workspace"]["id"], "argv":[claude]}),
        )
        .await
        .unwrap();
    assert_eq!(result["integration"]["status"], "configured");
    assert_eq!(result["integration"]["changed"], true);
    let pane = result["pane"]["id"].as_str().unwrap();
    wait_for_text(&mut c, pane, "hooks-loaded", Duration::from_secs(5)).await;
    for (event, state) in [
        ("SessionStart", "idle"),
        ("UserPromptSubmit", "working"),
        ("Notification", "blocked"),
        ("Stop", "done"),
    ] {
        c.call("pane.input", json!({"pane_id":pane,"data_b64":base64::engine::general_purpose::STANDARD.encode(format!("{event}\n"))})).await.unwrap();
        wait_for_text(
            &mut c,
            pane,
            &format!("event-delivered:{event}"),
            Duration::from_secs(5),
        )
        .await;
        let p = c.call("pane.get", json!({"pane_id":pane})).await.unwrap();
        assert_eq!(p["pane"]["lifecycle"], state);
    }
    let root: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
    assert_eq!(root["model"], "opus");
    assert_eq!(root["hooks"]["Stop"][0]["hooks"][0]["command"], "true");
    std::fs::remove_file(&file).unwrap();
    let split = c
        .call("pane.split", json!({"pane_id":pane,"argv":[claude]}))
        .await
        .unwrap();
    assert_eq!(split["integration"]["changed"], true);
    assert_eq!(split["pane"]["agent"]["kind"], "claude");
    wait_for_text(
        &mut c,
        split["pane"]["id"].as_str().unwrap(),
        "hooks-loaded",
        Duration::from_secs(5),
    )
    .await;
    srv.shutdown().await;
}

#[tokio::test]
async fn automatic_setup_failure_is_visible_without_stopping_the_agent() {
    let srv = TestServer::start().await;
    let claude = lifecycle_fixture(srv.socket.parent().unwrap(), "claude");
    let file = srv.integration_home.join(".claude/settings.json");
    std::fs::create_dir_all(file.parent().unwrap()).unwrap();
    std::fs::write(&file, "{broken").unwrap();
    let mut c = srv.client().await;
    let w = c
        .call("workspace.create", json!({"cwd":"/tmp"}))
        .await
        .unwrap();
    let r = c
        .call(
            "pane.spawn",
            json!({"workspace_id":w["workspace"]["id"],"argv":[claude]}),
        )
        .await
        .unwrap();
    assert_eq!(r["integration"]["status"], "error");
    assert!(r["integration"]["notice"]
        .as_str()
        .unwrap()
        .contains("invalid JSON"));
    wait_for_text(
        &mut c,
        r["pane"]["id"].as_str().unwrap(),
        "agent-started",
        Duration::from_secs(5),
    )
    .await;
    assert_eq!(std::fs::read_to_string(file).unwrap(), "{broken");
    srv.shutdown().await;
}

#[tokio::test]
async fn hints_shells_and_ambiguous_aliases_do_not_authorize_setup() {
    let srv = TestServer::start().await;
    let agent = lifecycle_fixture(srv.socket.parent().unwrap(), "agent");
    let mut c = srv.client().await;
    for argv in [json!(["sh", "-c", "read line"]), json!([agent])] {
        let w = c
            .call("workspace.create", json!({"cwd":"/tmp"}))
            .await
            .unwrap();
        let r = c
            .call(
                "pane.spawn",
                json!({"workspace_id":w["workspace"]["id"],"argv":argv,"agent_hint":"cursor"}),
            )
            .await
            .unwrap();
        assert!(r.get("integration").is_none());
    }
    assert!(!srv.integration_home.join(".cursor/hooks.json").exists());
    srv.shutdown().await;
}

#[tokio::test]
async fn explicit_launch_config_root_and_disabled_mode_are_respected() {
    let srv = TestServer::start().await;
    let claude = lifecycle_fixture(srv.socket.parent().unwrap(), "claude");
    let custom = srv.integration_home.join("custom-claude");
    let mut c = srv.client().await;
    let w = c
        .call("workspace.create", json!({"cwd":"/tmp"}))
        .await
        .unwrap();
    let r=c.call("pane.spawn",json!({"workspace_id":w["workspace"]["id"],"argv":[claude],"env":{"CLAUDE_CONFIG_DIR":custom}})).await.unwrap();
    assert_eq!(
        r["integration"]["file"],
        custom.join("settings.json").to_string_lossy().as_ref()
    );
    wait_for_text(
        &mut c,
        r["pane"]["id"].as_str().unwrap(),
        "hooks-loaded",
        Duration::from_secs(5),
    )
    .await;
    assert!(!srv.integration_home.join(".claude/settings.json").exists());
    let split = c
        .call(
            "pane.split",
            json!({"pane_id":r["pane"]["id"],"argv":[claude,"--bare"]}),
        )
        .await
        .unwrap();
    assert_eq!(split["integration"]["status"], "disabled");
    let alternate = c
        .call(
            "pane.split",
            json!({"pane_id":r["pane"]["id"],"argv":[claude,"--setting-sources=project,local"]}),
        )
        .await
        .unwrap();
    assert_eq!(alternate["integration"]["status"], "disabled");
    assert!(!srv.integration_home.join(".claude/settings.json").exists());
    srv.shutdown().await;
}

#[tokio::test]
async fn codex_local_runtime_routes_installed_hooks_to_each_pane() {
    use std::os::unix::fs::PermissionsExt;
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (ws, stale) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let codex = srv.socket.parent().unwrap().join("codex");
    std::fs::write(&codex, r#"#!/usr/bin/env python3
import json, os, pathlib, subprocess, sys
if sys.argv[1:] == ['--no-daemon', '--version']:
    print('codex 0.159.1'); sys.exit(0)
hooks = json.loads((pathlib.Path(os.environ['SIGNALTTY_INTEGRATION_HOME']) / '.codex/hooks.json').read_text())['hooks']
env = os.environ.copy()
if '--no-daemon' not in sys.argv[1:]:
    env['SIGNALTTY_PANE'] = os.environ['CODEX_TEST_STALE_PANE']
print('runtime-ready', flush=True)
for line in sys.stdin:
    event = line.strip()
    for group in hooks.get(event, []):
        for hook in group['hooks']:
            subprocess.run(hook['command'], shell=True, input=b'{"session_id":"fixture-local"}', env=env, check=True)
    print('delivered:' + event, flush=True)
"#).unwrap();
    std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o700)).unwrap();
    let tab = c
        .call("tab.create", json!({"workspace_id":ws}))
        .await
        .unwrap();
    let first = c
        .call(
            "pane.spawn",
            json!({"workspace_id":ws,"tab_id":tab["tab"]["id"],"argv":[codex],
        "env":{"CODEX_TEST_STALE_PANE":stale}}),
        )
        .await
        .unwrap();
    let first = first["pane"]["id"].as_str().unwrap().to_owned();
    let second = c
        .call(
            "pane.split",
            json!({"pane_id":first,"direction":"right","argv":[codex],
        "env":{"CODEX_TEST_STALE_PANE":stale}}),
        )
        .await
        .unwrap();
    let second = second["pane"]["id"].as_str().unwrap().to_owned();
    let hooks = std::fs::read(srv.integration_home.join(".codex/hooks.json")).unwrap();
    for (pane, event, state) in [
        (&first, "UserPromptSubmit", "working"),
        (&second, "Stop", "done"),
    ] {
        wait_for_text(&mut c, pane, "runtime-ready", Duration::from_secs(5)).await;
        c.call(
            "pane.input",
            json!({"pane_id":pane,"data_b64":base64_encode(&format!("{event}\n"))}),
        )
        .await
        .unwrap();
        wait_for_text(
            &mut c,
            pane,
            &format!("delivered:{event}"),
            Duration::from_secs(5),
        )
        .await;
        assert_eq!(
            c.call("pane.get", json!({"pane_id":pane})).await.unwrap()["pane"]["lifecycle"],
            state
        );
    }
    assert_eq!(
        c.call("pane.get", json!({"pane_id":first})).await.unwrap()["pane"]["lifecycle"],
        "working"
    );
    assert_eq!(
        c.call("pane.get", json!({"pane_id":stale})).await.unwrap()["pane"]["lifecycle"],
        "unknown"
    );
    assert_eq!(
        hooks,
        std::fs::read(srv.integration_home.join(".codex/hooks.json")).unwrap()
    );
    srv.shutdown().await;
}

#[tokio::test]
async fn codex_unsupported_or_hanging_probe_keeps_original_launch_with_notice() {
    use std::os::unix::fs::PermissionsExt;
    for (probe, bad_config) in [
        ("exit 2", false),
        ("sleep 30 & echo $! > probe-child.pid; wait", false),
        ("exit 2", true),
    ] {
        let srv = TestServer::start().await;
        let codex = srv.socket.parent().unwrap().join("codex");
        if bad_config {
            let config = srv.integration_home.join(".codex/hooks.json");
            std::fs::create_dir_all(config.parent().unwrap()).unwrap();
            std::fs::write(config, "broken-config").unwrap();
        }
        std::fs::write(&codex, format!("#!/bin/sh\nif [ \"$1\" = --no-daemon ] && [ \"$2\" = --version ]; then {probe}; fi\nprintf 'fallback-ready:%s\\n' \"$*\"\nread line\n")).unwrap();
        std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o700)).unwrap();
        let mut c = srv.client().await;
        let ws = c
            .call(
                "workspace.create",
                json!({"cwd":srv.socket.parent().unwrap()}),
            )
            .await
            .unwrap();
        let started = std::time::Instant::now();
        let launch = c
            .call(
                "pane.spawn",
                json!({"workspace_id":ws["workspace"]["id"],"argv":[codex,"--model","exec"]}),
            )
            .await
            .unwrap();
        assert!(started.elapsed() < Duration::from_secs(3));
        assert_eq!(
            launch["integration"]["status"],
            if bad_config { "error" } else { "disabled" }
        );
        assert!(launch["integration"]["notice"]
            .as_str()
            .unwrap()
            .contains("--no-daemon"));
        let pane = launch["pane"]["id"].as_str().unwrap();
        wait_for_text(
            &mut c,
            pane,
            "fallback-ready:--model exec",
            Duration::from_secs(3),
        )
        .await;
        assert_eq!(launch["pane"]["argv"], json!([codex, "--model", "exec"]));
        let pid_file = srv.socket.parent().unwrap().join("probe-child.pid");
        let child_stopped = if pid_file.exists() {
            let pid = std::fs::read_to_string(&pid_file).unwrap();
            let path = format!("/proc/{}/stat", pid.trim());
            tokio::time::sleep(Duration::from_millis(50)).await;
            std::fs::read_to_string(path)
                .map(|stat| stat.split_whitespace().nth(2) == Some("Z"))
                .unwrap_or(true)
        } else {
            true
        };
        if !child_stopped {
            let pid: i32 = std::fs::read_to_string(&pid_file)
                .unwrap()
                .trim()
                .parse()
                .unwrap();
            let _ = nix::sys::signal::kill(
                nix::unistd::Pid::from_raw(pid),
                nix::sys::signal::Signal::SIGKILL,
            );
        }
        srv.shutdown().await;
        assert!(
            child_stopped,
            "timed-out version wrapper left its child running"
        );
    }
}

#[tokio::test]
async fn request_split_across_writes_survives_interleaved_events() {
    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
    let srv = TestServer::start().await;
    let mut other = srv.client().await;
    let (r, mut w) = tokio::net::UnixStream::connect(&srv.socket)
        .await
        .unwrap()
        .into_split();
    let mut reader = BufReader::new(r);
    let req = r#"{"protocol":"signaltty/1","id":"split","method":"server.status","params":{}}"#;
    let (head, tail) = req.split_at(req.len() / 2);
    w.write_all(head.as_bytes()).await.unwrap();
    tokio::time::sleep(Duration::from_millis(100)).await;
    // Any broadcast wakes the connection's event branch mid-line.
    other
        .call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    tokio::time::sleep(Duration::from_millis(100)).await;
    w.write_all(format!("{tail}\n").as_bytes()).await.unwrap();
    let mut line = String::new();
    tokio::time::timeout(Duration::from_secs(5), reader.read_line(&mut line))
        .await
        .expect("response timeout")
        .unwrap();
    let resp: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
    assert_eq!(resp["id"], "split", "{resp}");
    assert_eq!(resp["ok"], true, "{resp}");
    srv.shutdown().await;
}

fn processes_with_arg(arg: &str) -> usize {
    std::fs::read_dir("/proc")
        .unwrap()
        .filter_map(|e| std::fs::read(e.ok()?.path().join("cmdline")).ok())
        .filter(|cmd| cmd.split(|&b| b == 0).any(|a| a == arg.as_bytes()))
        .count()
}

#[tokio::test]
async fn close_with_unknown_signal_is_rejected_and_keeps_child() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let arg = format!(
        "{}.{}",
        90000 + std::process::id() % 9000,
        std::process::id()
    );
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", &arg]).await;
    tokio::time::sleep(Duration::from_millis(200)).await;
    assert_eq!(processes_with_arg(&arg), 1);
    let err = c
        .call("pane.close", json!({"pane_id": pane, "signal": "BOGUS"}))
        .await
        .unwrap_err();
    assert!(err.starts_with("BAD_PARAMS"), "{err}");
    let err = c
        .call("pane.signal", json!({"pane_id": pane, "signal": "BOGUS"}))
        .await
        .unwrap_err();
    assert!(err.starts_with("BAD_PARAMS"), "{err}");
    c.call("pane.close", json!({"pane_id": pane}))
        .await
        .unwrap();
    tokio::time::sleep(Duration::from_millis(500)).await;
    assert_eq!(processes_with_arg(&arg), 0, "child outlived its pane");
    srv.shutdown().await;
}

#[tokio::test]
async fn attach_to_missing_pane_emits_no_resize() {
    let srv = TestServer::start().await;
    let mut events = srv.client().await;
    let mut c = srv.client().await;
    events
        .call("subscribe", json!({"events": ["*"]}))
        .await
        .unwrap();
    let err = c
        .call(
            "pane.attach",
            json!({"pane_id": "pane_missing", "cols": 100, "rows": 30}),
        )
        .await
        .unwrap_err();
    assert!(err.starts_with("NO_SUCH_PANE"), "{err}");
    c.call("workspace.create", json!({"cwd": "/tmp"}))
        .await
        .unwrap();
    let ev = events.read_events(1, Duration::from_secs(5)).await;
    assert_eq!(ev[0]["event"], "workspace.created", "{}", ev[0]);
    srv.shutdown().await;
}

#[tokio::test]
async fn concurrent_workspace_creates_get_distinct_handles() {
    let srv = TestServer::start().await;
    let mut tasks = tokio::task::JoinSet::new();
    for _ in 0..8 {
        let mut c = srv.client().await;
        tasks.spawn(async move {
            c.call("workspace.create", json!({"cwd": "/tmp", "name": "api"}))
                .await
                .unwrap()["workspace"]["handle"]
                .as_str()
                .unwrap()
                .to_string()
        });
    }
    let mut handles = tasks.join_all().await;
    handles.sort();
    handles.dedup();
    assert_eq!(handles.len(), 8, "{handles:?}");
    srv.shutdown().await;
}

#[tokio::test]
async fn tool_hooks_start_work_but_never_clear_blocked() {
    let srv = TestServer::start().await;
    let mut c = srv.client().await;
    let (_ws, pane) = new_pane(&mut c, vec!["sleep", "30"]).await;
    let hook = |event: &str| json!({"agent": "claude", "event": event, "pane_id": pane});

    c.call("hook-event", hook("SessionStart")).await.unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_ne!(p["pane"]["lifecycle"], "working");
    c.call("hook-event", hook("PreToolUse")).await.unwrap();
    let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
    assert_eq!(p["pane"]["lifecycle"], "working", "idle pane starts work");

    c.call("hook-event", hook("PermissionRequest"))
        .await
        .unwrap();
    for event in ["PreToolUse", "PostToolUse", "PreCompact", "PostCompact"] {
        c.call("hook-event", hook(event)).await.unwrap();
        let p = c.call("pane.get", json!({"pane_id": pane})).await.unwrap();
        assert_eq!(p["pane"]["lifecycle"], "blocked", "{event} kept blocked");
        assert_eq!(p["pane"]["attention"], "permission_required");
    }
    srv.shutdown().await;
}

/// A server whose test never calls `shutdown` (panic, early return) must
/// still be reaped when it drops.
#[tokio::test]
async fn dropped_test_server_is_reaped() {
    let srv = TestServer::start().await;
    let socket = srv.socket.to_string_lossy().to_string();
    let running = || {
        std::fs::read_dir("/proc").unwrap().flatten().any(|e| {
            std::fs::read(e.path().join("cmdline"))
                .is_ok_and(|c| String::from_utf8_lossy(&c).contains(&socket))
        })
    };
    assert!(running(), "server should be running");
    drop(srv);
    assert!(!running(), "dropped server is still running");
}