tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definiciones

shell

c22d2480b93b7802be8e8ea5a0075b8175c4fedc · por sergio · (hace 5 meses) · padre 3d55f189c · ver el árbol en este commit

Delta semántico · 295

crates/apps/file_explorer/src/lib.rs

  • cambió por dentro fn new pub

crates/apps/shipote-cli/src/main.rs

crates/apps/shipote-daemon/src/main.rs

crates/apps/shipote-shell/src/main.rs

crates/apps/text_viewer/src/lib.rs

crates/core/ente-soma/src/lib.rs

crates/modules/nouser/core/src/cluster.rs

crates/modules/shipote/shipote-card/src/lib.rs

crates/modules/shipote/shipote-core/src/lib.rs

crates/modules/shipote/shipote-core/src/logbuf.rs

crates/modules/shipote/shipote-core/src/persist.rs

crates/modules/shipote/shipote-core/src/pipeline.rs

crates/modules/shipote/shipote-discern/src/lib.rs

crates/modules/shipote/shipote-protocol/src/lib.rs

crates/modules/ui_engine/libs/providers/fs/src/lib.rs

crates/shared/ente-incarnate/src/caps.rs

crates/shared/ente-incarnate/src/cgroup.rs

crates/shared/ente-incarnate/src/child.rs

crates/shared/ente-incarnate/src/env.rs

crates/shared/ente-incarnate/src/error.rs

crates/shared/ente-incarnate/src/lib.rs

crates/shared/ente-incarnate/src/namespaced.rs

crates/shared/ente-incarnate/src/plain.rs

36 archivos · +5159 −364

diff --git a/Cargo.lock b/Cargo.lock
index d35c30c..9185e17 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2884,6 +2884,19 @@ dependencies = [
  "zbus 4.4.0",
 ]
 
+[[package]]
+name = "ente-incarnate"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "brahman-card",
+ "libc",
+ "nix 0.29.0",
+ "tempfile",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
 [[package]]
 name = "ente-journald-compat"
 version = "0.0.1"
@@ -3023,7 +3036,7 @@ dependencies = [
  "anyhow",
  "ente-bus",
  "ente-card",
- "libc",
+ "ente-incarnate",
  "nix 0.29.0",
  "tracing",
 ]
@@ -6886,6 +6899,7 @@ dependencies = [
  "nouser-nous",
  "serde",
  "serde_json",
+ "shipote-discern",
  "sled",
  "tempfile",
  "thiserror 2.0.18",
@@ -9571,6 +9585,111 @@ dependencies = [
  "lazy_static",
 ]
 
+[[package]]
+name = "shipote-card"
+version = "0.1.0"
+dependencies = [
+ "brahman-card",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "toml 0.8.23",
+ "ulid",
+]
+
+[[package]]
+name = "shipote-cli"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "brahman-card",
+ "clap",
+ "serde_json",
+ "shipote-card",
+ "shipote-protocol",
+ "tokio",
+ "ulid",
+]
+
+[[package]]
+name = "shipote-core"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "brahman-card",
+ "ente-incarnate",
+ "libc",
+ "nix 0.29.0",
+ "serde",
+ "serde_json",
+ "shipote-card",
+ "shipote-discern",
+ "tempfile",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "ulid",
+]
+
+[[package]]
+name = "shipote-daemon"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "brahman-card",
+ "brahman-sidecar",
+ "ente-incarnate",
+ "nix 0.29.0",
+ "shipote-card",
+ "shipote-core",
+ "shipote-discern",
+ "shipote-protocol",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "ulid",
+]
+
+[[package]]
+name = "shipote-discern"
+version = "0.1.0"
+dependencies = [
+ "brahman-card",
+ "serde",
+ "serde_json",
+ "toml 0.8.23",
+]
+
+[[package]]
+name = "shipote-protocol"
+version = "0.1.0"
+dependencies = [
+ "brahman-card",
+ "nix 0.29.0",
+ "postcard",
+ "serde",
+ "shipote-card",
+ "thiserror 2.0.18",
+ "tokio",
+ "ulid",
+]
+
+[[package]]
+name = "shipote-shell"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "shipote-card",
+ "shipote-protocol",
+ "tokio",
+ "ulid",
+ "yahweh-launcher",
+ "yahweh-theme",
+ "yahweh-widget-app-header",
+ "yahweh-widget-banner",
+ "yahweh-widget-stat-card",
+]
+
 [[package]]
 name = "shlex"
 version = "1.3.0"
@@ -12999,6 +13118,7 @@ version = "0.1.0"
 dependencies = [
  "async-trait",
  "notify",
+ "shipote-discern",
  "tokio",
  "yahweh-core",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index ab92a77..11e2a1d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,6 +12,7 @@ members = [
     "crates/core/brahman-admin",
     "crates/shared/brahman-sidecar",
     "crates/shared/brahman-net",
+    "crates/shared/ente-incarnate",
     "crates/core/ente-card",
     "crates/core/ente-bus",
     "crates/core/ente-cas",
@@ -84,6 +85,14 @@ members = [
     "crates/modules/nouser/nous-mock",
     "crates/modules/nouser/nous-real",
 
+    # ============================================================
+    # modules/shipote/ — runtime de espacios aislados con flujo tipado
+    # ============================================================
+    "crates/modules/shipote/shipote-card",
+    "crates/modules/shipote/shipote-protocol",
+    "crates/modules/shipote/shipote-discern",
+    "crates/modules/shipote/shipote-core",
+
     # ============================================================
     # apps/ — apps que consumen el protocolo (yahweh modules+shell)
     # ============================================================
@@ -98,6 +107,9 @@ members = [
     "crates/apps/minga-explorer",
     "crates/apps/brahman-broker-explorer",
     "crates/apps/brahman-demo",
+    "crates/apps/shipote-daemon",
+    "crates/apps/shipote-cli",
+    "crates/apps/shipote-shell",
 ]
 
 [workspace.package]
diff --git a/crates/apps/file_explorer/src/lib.rs b/crates/apps/file_explorer/src/lib.rs
index 4b96cc3..d7eacb9 100644
--- a/crates/apps/file_explorer/src/lib.rs
+++ b/crates/apps/file_explorer/src/lib.rs
@@ -111,7 +111,7 @@ impl FileExplorer {
 
         let mut me = Self {
             tree_view,
-            provider: Arc::new(FileDataProvider),
+            provider: Arc::new(FileDataProvider::new()),
             root: root.clone(),
             expanded,
             children: HashMap::new(),
diff --git a/crates/apps/shipote-cli/Cargo.toml b/crates/apps/shipote-cli/Cargo.toml
new file mode 100644
index 0000000..d4f4b58
--- /dev/null
+++ b/crates/apps/shipote-cli/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "shipote-cli"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "CLI de administración de shipote-daemon."
+
+[[bin]]
+name = "shipote"
+path = "src/main.rs"
+
+[dependencies]
+shipote-card = { path = "../../modules/shipote/shipote-card" }
+shipote-protocol = { path = "../../modules/shipote/shipote-protocol" }
+brahman-card = { path = "../../core/brahman-card" }
+anyhow = { workspace = true }
+clap = { workspace = true }
+tokio = { workspace = true }
+serde_json = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/apps/shipote-cli/src/main.rs b/crates/apps/shipote-cli/src/main.rs
new file mode 100644
index 0000000..eceb3d1
--- /dev/null
+++ b/crates/apps/shipote-cli/src/main.rs
@@ -0,0 +1,391 @@
+//! `shipote` — CLI de administración del daemon.
+
+use anyhow::{anyhow, Context, Result};
+use clap::{Parser, Subcommand};
+use shipote_card::{load_pipeline_spec, load_workspace_spec, WorkspaceId};
+use shipote_protocol::{default_socket_path, read_frame, write_frame, Request, Response};
+use std::path::PathBuf;
+use tokio::net::UnixStream;
+use ulid::Ulid;
+
+#[derive(Parser, Debug)]
+#[command(name = "shipote", version, about = "Administración de shipote-daemon")]
+struct Cli {
+    /// Path al socket del daemon. Default: $XDG_RUNTIME_DIR/shipote.sock.
+    #[arg(long, global = true)]
+    socket: Option<PathBuf>,
+
+    #[command(subcommand)]
+    cmd: Cmd,
+}
+
+#[derive(Subcommand, Debug)]
+enum Cmd {
+    /// Health-check del daemon.
+    Ping,
+
+    /// Capacidades runtime detectadas por el daemon.
+    Caps,
+
+    /// Operaciones sobre Workspaces.
+    #[command(subcommand)]
+    Workspace(WsCmd),
+
+    /// Ejecutar un comando one-shot dentro de un workspace.
+    Run {
+        /// ULID del workspace destino.
+        #[arg(short = 'w', long)]
+        workspace: String,
+        /// Path del ejecutable.
+        exec: String,
+        /// Argumentos del comando.
+        argv: Vec<String>,
+    },
+
+    /// Discernir el tipo de un archivo (ad-hoc, sin workspace).
+    Discern {
+        /// Path al archivo a discernir.
+        path: PathBuf,
+    },
+
+    /// Listar comandos de un workspace.
+    Commands {
+        /// ULID del workspace.
+        workspace: String,
+    },
+
+    /// Mostrar tail del log capturado de un comando.
+    Logs {
+        /// ULID del workspace.
+        workspace: String,
+        /// ULID del comando.
+        command: String,
+        /// Bytes desde el final (0 = todo).
+        #[arg(long, default_value_t = 0)]
+        tail: usize,
+    },
+
+    /// Pipeline DAG con flujo tipado.
+    #[command(subcommand)]
+    Pipeline(PipeCmd),
+}
+
+#[derive(Subcommand, Debug)]
+enum PipeCmd {
+    /// Lanzar un Pipeline desde un spec TOML/JSON.
+    Run {
+        /// Path al spec del pipeline.
+        spec: PathBuf,
+        /// Interponer un tap entre productor↔consumidor de cada edge para
+        /// discernir el TypeRef del flujo.
+        #[arg(long)]
+        tap: bool,
+    },
+    /// Guardar un pipeline bajo un nombre (persiste con el snapshot).
+    Save {
+        /// Nombre simbólico.
+        name: String,
+        /// Path al spec.
+        spec: PathBuf,
+    },
+    /// Listar nombres de pipelines guardados.
+    SavedList,
+    /// Eliminar un pipeline guardado.
+    Drop { name: String },
+    /// Ejecutar un pipeline guardado por nombre.
+    RunSaved {
+        name: String,
+        #[arg(long)]
+        tap: bool,
+    },
+}
+
+#[derive(Subcommand, Debug)]
+enum WsCmd {
+    /// Crear un workspace desde un spec TOML/JSON.
+    Create {
+        /// Path al spec del workspace.
+        spec: PathBuf,
+    },
+    /// Listar workspaces vivos.
+    List,
+    /// Detener un workspace por ID.
+    Stop {
+        id: String,
+    },
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+    let cli = Cli::parse();
+    let socket = cli.socket.unwrap_or_else(default_socket_path);
+    let mut stream = UnixStream::connect(&socket)
+        .await
+        .with_context(|| format!("connect {}", socket.display()))?;
+
+    match cli.cmd {
+        Cmd::Ping => {
+            let resp = round_trip(&mut stream, Request::Ping).await?;
+            match resp {
+                Response::Pong => println!("pong"),
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Caps => {
+            let resp = round_trip(&mut stream, Request::Capabilities).await?;
+            match resp {
+                Response::Capabilities {
+                    kernel_version,
+                    user_ns,
+                    cgroup_v2,
+                    cgroup_delegated,
+                    has_cap_sys_admin,
+                } => {
+                    println!("kernel:           {}.{}.{}", kernel_version.0, kernel_version.1, kernel_version.2);
+                    println!("user_ns:          {user_ns}");
+                    println!("cgroup_v2:        {cgroup_v2}");
+                    println!("cgroup_delegated: {cgroup_delegated}");
+                    println!("cap_sys_admin:    {has_cap_sys_admin}");
+                }
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Workspace(WsCmd::Create { spec }) => {
+            let ws = load_workspace_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
+            let resp = round_trip(&mut stream, Request::WorkspaceCreate { spec: ws }).await?;
+            match resp {
+                Response::WorkspaceCreated { id, warnings } => {
+                    println!("{id}");
+                    for w in warnings {
+                        eprintln!("warning: {w}");
+                    }
+                }
+                Response::Error { message } => return Err(anyhow!(message)),
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Workspace(WsCmd::List) => {
+            let resp = round_trip(&mut stream, Request::WorkspaceList).await?;
+            match resp {
+                Response::WorkspaceList { items } => {
+                    if items.is_empty() {
+                        println!("(no workspaces)");
+                    }
+                    for it in items {
+                        println!(
+                            "{}  {:<20}  cmds={}  uptime={}ms",
+                            it.id, it.label, it.commands, it.uptime_ms
+                        );
+                    }
+                }
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Workspace(WsCmd::Stop { id }) => {
+            let id = parse_ws_id(&id)?;
+            let resp = round_trip(&mut stream, Request::WorkspaceStop { id }).await?;
+            match resp {
+                Response::WorkspaceStopped { id, reaped } => {
+                    println!("stopped {id} (reaped {reaped})");
+                }
+                Response::Error { message } => return Err(anyhow!(message)),
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Run { workspace, exec, argv } => {
+            let id = parse_ws_id(&workspace)?;
+            let resp = round_trip(
+                &mut stream,
+                Request::Run {
+                    workspace: id,
+                    exec,
+                    argv,
+                    envp: vec![],
+                },
+            )
+            .await?;
+            match resp {
+                Response::RunStarted { command_id, pid, .. } => {
+                    println!("{command_id}  pid={pid}");
+                }
+                Response::Error { message } => return Err(anyhow!(message)),
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Pipeline(PipeCmd::Run { spec, tap }) => {
+            let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
+            let resp = round_trip(&mut stream, Request::PipelineRun { spec: p, tap }).await?;
+            print_pipeline_started(resp)?;
+        }
+
+        Cmd::Pipeline(PipeCmd::Save { name, spec }) => {
+            let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
+            let resp = round_trip(&mut stream, Request::PipelineSave { name: name.clone(), spec: p }).await?;
+            match resp {
+                Response::PipelineSaved { name } => println!("saved {name}"),
+                Response::Error { message } => return Err(anyhow!(message)),
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Pipeline(PipeCmd::SavedList) => {
+            let resp = round_trip(&mut stream, Request::PipelineSavedList).await?;
+            match resp {
+                Response::PipelineSavedList { names } => {
+                    if names.is_empty() {
+                        println!("(no saved pipelines)");
+                    }
+                    for n in names {
+                        println!("{n}");
+                    }
+                }
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Pipeline(PipeCmd::Drop { name }) => {
+            let resp = round_trip(&mut stream, Request::PipelineDrop { name }).await?;
+            match resp {
+                Response::PipelineDropped { name, existed } => {
+                    if existed {
+                        println!("dropped {name}");
+                    } else {
+                        eprintln!("no existía: {name}");
+                    }
+                }
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Pipeline(PipeCmd::RunSaved { name, tap }) => {
+            let resp = round_trip(&mut stream, Request::PipelineRunSaved { name, tap }).await?;
+            print_pipeline_started(resp)?;
+        }
+
+        Cmd::Commands { workspace } => {
+            let ws = parse_ws_id(&workspace)?;
+            let resp = round_trip(&mut stream, Request::CommandList { workspace: ws }).await?;
+            match resp {
+                Response::CommandList { items } => {
+                    if items.is_empty() {
+                        println!("(no commands)");
+                    }
+                    for c in items {
+                        let alive = if c.alive { "alive" } else { "exited" };
+                        let exit = c
+                            .exit_status
+                            .map(|s| format!("exit={s}"))
+                            .unwrap_or_default();
+                        println!(
+                            "{}  {:<24} pid={:<7} {:<8} logs={} {}",
+                            c.id, c.label, c.pid, alive, c.log_bytes, exit
+                        );
+                    }
+                }
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Logs { workspace, command, tail } => {
+            let ws = parse_ws_id(&workspace)?;
+            let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?;
+            let resp = round_trip(
+                &mut stream,
+                Request::CommandLogs {
+                    workspace: ws,
+                    command: cmd_id,
+                    tail_bytes: tail,
+                },
+            )
+            .await?;
+            match resp {
+                Response::CommandLogs { bytes } => {
+                    // stdout raw, sin decoding — el log puede tener bytes binarios.
+                    use std::io::Write;
+                    let _ = std::io::stdout().write_all(&bytes);
+                    let _ = std::io::stdout().flush();
+                }
+                Response::Error { message } => return Err(anyhow!(message)),
+                other => print_unexpected(&other),
+            }
+        }
+
+        Cmd::Discern { path } => {
+            let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
+            // Sample: hasta 4 KiB.
+            let sample = bytes.into_iter().take(4096).collect();
+            let resp = round_trip(
+                &mut stream,
+                Request::Discern {
+                    sample,
+                    hint_path: Some(path),
+                },
+            )
+            .await?;
+            match resp {
+                Response::Discernment { ty, confidence, mime, lens } => {
+                    println!("type:       {ty}");
+                    println!("confidence: {confidence:.2}");
+                    if let Some(m) = mime {
+                        println!("mime:       {m}");
+                    }
+                    if let Some(l) = lens {
+                        println!("lens:       {l}");
+                    }
+                }
+                Response::Error { message } => return Err(anyhow!(message)),
+                other => print_unexpected(&other),
+            }
+        }
+    }
+
+    Ok(())
+}
+
+async fn round_trip(stream: &mut UnixStream, req: Request) -> Result<Response> {
+    write_frame(stream, &req).await?;
+    let resp: Response = read_frame(stream).await?;
+    Ok(resp)
+}
+
+fn parse_ws_id(s: &str) -> Result<WorkspaceId> {
+    let u = Ulid::from_string(s).map_err(|e| anyhow!("invalid workspace id: {e}"))?;
+    Ok(WorkspaceId(u))
+}
+
+fn print_unexpected(r: &Response) {
+    eprintln!("unexpected response: {r:?}");
+}
+
+fn print_pipeline_started(resp: Response) -> Result<()> {
+    match resp {
+        Response::PipelineStarted { pipeline, command_pids, edges } => {
+            println!("pipeline {pipeline}");
+            for (label, pid) in command_pids {
+                println!("  {:<20} pid={pid}", label);
+            }
+            if !edges.is_empty() {
+                println!("edges:");
+                for e in edges {
+                    println!(
+                        "  {}.{} → {}.{}  ty={:?}  mime={:?}  conf={:.2}",
+                        e.from_label, e.from_output, e.to_label, e.to_input,
+                        e.ty, e.mime, e.confidence,
+                    );
+                }
+            }
+            Ok(())
+        }
+        Response::Error { message } => Err(anyhow!(message)),
+        other => {
+            print_unexpected(&other);
+            Ok(())
+        }
+    }
+}
diff --git a/crates/apps/shipote-daemon/Cargo.toml b/crates/apps/shipote-daemon/Cargo.toml
new file mode 100644
index 0000000..1f08310
--- /dev/null
+++ b/crates/apps/shipote-daemon/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "shipote-daemon"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Daemon de shipote: dueño de los Workspaces, expone admin socket para shipote-cli."
+
+[[bin]]
+name = "shipote-daemon"
+path = "src/main.rs"
+
+[dependencies]
+shipote-card = { path = "../../modules/shipote/shipote-card" }
+shipote-protocol = { path = "../../modules/shipote/shipote-protocol" }
+shipote-discern = { path = "../../modules/shipote/shipote-discern" }
+shipote-core = { path = "../../modules/shipote/shipote-core" }
+ente-incarnate = { path = "../../shared/ente-incarnate" }
+brahman-card = { path = "../../core/brahman-card" }
+brahman-sidecar = { path = "../../shared/brahman-sidecar" }
+anyhow = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+ulid = { workspace = true }
+nix = { workspace = true }
diff --git a/crates/apps/shipote-daemon/src/main.rs b/crates/apps/shipote-daemon/src/main.rs
new file mode 100644
index 0000000..76512b0
--- /dev/null
+++ b/crates/apps/shipote-daemon/src/main.rs
@@ -0,0 +1,361 @@
+//! `shipote-daemon` — punto de entrada del runtime de shipote.
+//!
+//! Responsabilidades:
+//! - Escuchar el Unix socket admin (default: `$XDG_RUNTIME_DIR/shipote.sock`).
+//! - Despachar mensajes del [`shipote_protocol`] al [`WorkspaceManager`].
+//! - Reapear hijos periódicamente.
+//!
+//! Lo que NO hace en v1:
+//! - Sidecar al broker / handshake con Init (futuro: cuando un workspace
+//!   exponga `service_socket`, anunciar al broker).
+//! - GUI (futuro `shipote-shell` con yahweh_launcher).
+
+use anyhow::Context;
+use brahman_card::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Supervision, TypeRef};
+use ente_incarnate::IncarnatorConfig;
+use shipote_core::WorkspaceManager;
+use shipote_discern::{DiscernPipeline, Hint};
+use shipote_protocol::{
+    default_socket_path, read_frame, write_frame, CommandInfo as ProtoCommandInfo,
+    EdgeDiscernmentInfo, Request, Response, WorkspaceSummary,
+};
+use std::sync::Arc;
+use tokio::net::{UnixListener, UnixStream};
+use tracing::{error, info, warn};
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    init_tracing();
+    let sock = default_socket_path();
+    if sock.exists() {
+        // Si ya existe, asumimos restart limpio. Si hubiera otro daemon vivo,
+        // bind fallaría con EADDRINUSE — más adelante: lockfile + check de PID.
+        let _ = std::fs::remove_file(&sock);
+    }
+    if let Some(parent) = sock.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
+    info!(socket = %sock.display(), "shipote-daemon listening");
+
+    // Sidecar al broker: shipote se anuncia como sesión. Si el Init no
+    // está corriendo, el sidecar loguea y termina; el daemon sigue
+    // standalone (UX de v1: ningún feature requiere broker).
+    brahman_sidecar::spawn(build_daemon_card(&sock));
+
+    let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig {
+        // El daemon aún no se conecta al broker; cuando lo haga, este path
+        // se llenará desde el handshake.
+        bus_sock: None,
+        notify_socket: None,
+        extra_env: vec![("SHIPOTE_DAEMON".into(), "1".into())],
+        // strict_caps=false en v1: queremos UX permisiva (correr en non-root
+        // sin user_ns y avisar via warnings, no abortar).
+        strict_caps: false,
+    }));
+
+    // Restaurar snapshot previo si existe. Workspaces se recrean; los
+    // pids de comandos viejos NO se recuperan (kernel los mató).
+    let snapshot_path = shipote_core::persist::default_snapshot_path();
+    if let Err(e) = mgr.restore_snapshot(&snapshot_path).await {
+        warn!(?e, "restore_snapshot falló — start fresh");
+    }
+
+    // Save-on-shutdown via SIGTERM/SIGINT handler. tokio::signal soporta
+    // ambos en Linux.
+    {
+        let mgr = mgr.clone();
+        let path = snapshot_path.clone();
+        tokio::spawn(async move {
+            let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
+                .expect("SIGTERM handler");
+            let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
+                .expect("SIGINT handler");
+            tokio::select! {
+                _ = term.recv() => info!("SIGTERM — saving snapshot"),
+                _ = int.recv() => info!("SIGINT — saving snapshot"),
+            }
+            if let Err(e) = mgr.save_snapshot(&path).await {
+                warn!(?e, "save_snapshot falló");
+            }
+            std::process::exit(0);
+        });
+    }
+
+    let discerner = Arc::new(DiscernPipeline::default_pipeline());
+
+    // Reaper periódico cada 500 ms.
+    {
+        let mgr = mgr.clone();
+        tokio::spawn(async move {
+            let mut tick = tokio::time::interval(std::time::Duration::from_millis(500));
+            loop {
+                tick.tick().await;
+                mgr.reap_dead().await;
+            }
+        });
+    }
+
+    loop {
+        match listener.accept().await {
+            Ok((stream, _)) => {
+                let mgr = mgr.clone();
+                let disc = discerner.clone();
+                tokio::spawn(async move {
+                    if let Err(e) = handle_client(stream, mgr, disc).await {
+                        warn!(?e, "client handler error");
+                    }
+                });
+            }
+            Err(e) => {
+                error!(?e, "accept failed");
+                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+            }
+        }
+    }
+}
+
+async fn handle_client(
+    mut stream: UnixStream,
+    mgr: Arc<WorkspaceManager>,
+    disc: Arc<DiscernPipeline>,
+) -> anyhow::Result<()> {
+    loop {
+        let req: Request = match read_frame(&mut stream).await {
+            Ok(r) => r,
+            Err(shipote_protocol::ProtocolError::Closed) => return Ok(()),
+            Err(e) => return Err(e.into()),
+        };
+        let resp = dispatch(&mgr, &disc, req).await;
+        write_frame(&mut stream, &resp).await?;
+    }
+}
+
+async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Request) -> Response {
+    match req {
+        Request::Ping => Response::Pong,
+
+        Request::WorkspaceCreate { spec } => match mgr.create(spec).await {
+            Ok((id, warnings)) => Response::WorkspaceCreated { id, warnings },
+            Err(e) => Response::Error { message: format!("{e}") },
+        },
+
+        Request::WorkspaceList => {
+            let items = mgr
+                .list()
+                .await
+                .into_iter()
+                .map(|s| WorkspaceSummary {
+                    id: s.id,
+                    label: s.label,
+                    commands: s.commands,
+                    uptime_ms: s.uptime_ms,
+                })
+                .collect();
+            Response::WorkspaceList { items }
+        }
+
+        Request::WorkspaceStop { id } => match mgr.stop(id).await {
+            Ok(reaped) => Response::WorkspaceStopped { id, reaped },
+            Err(e) => Response::Error { message: format!("{e}") },
+        },
+
+        Request::Run { workspace, exec, argv, envp } => {
+            match mgr.run(workspace, exec, argv, envp).await {
+                Ok(s) => Response::RunStarted {
+                    workspace,
+                    command_id: s.id,
+                    pid: s.pid,
+                },
+                Err(e) => Response::Error { message: format!("{e}") },
+            }
+        }
+
+        Request::PipelineRun { spec, tap } => {
+            let disc = DiscernPipeline::default_pipeline();
+            let inc = mgr.incarnator_handle();
+            let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
+            match shipote_core::pipeline::run_pipeline(
+                &spec,
+                &ws_label,
+                tap,
+                std::sync::Arc::new(disc),
+                inc,
+            )
+            .await
+            {
+                Ok(launch) => Response::PipelineStarted {
+                    pipeline: launch.pipeline,
+                    command_pids: launch.command_pids,
+                    edges: launch
+                        .edge_discernments
+                        .into_iter()
+                        .map(|e| EdgeDiscernmentInfo {
+                            from_label: e.from_label,
+                            from_output: e.from_output,
+                            to_label: e.to_label,
+                            to_input: e.to_input,
+                            ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
+                            mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
+                            lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
+                            confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
+                        })
+                        .collect(),
+                },
+                Err(e) => Response::Error { message: format!("{e}") },
+            }
+        }
+
+        Request::Discern { sample, hint_path } => {
+            let path_str = hint_path.as_ref().and_then(|p| p.to_str());
+            let hint = Hint {
+                path: path_str,
+                size_total: None,
+            };
+            match disc.discern(&sample, &hint) {
+                Some(d) => Response::Discernment {
+                    ty: format!("{:?}", d.ty),
+                    confidence: d.confidence,
+                    mime: d.mime,
+                    lens: d.lens,
+                },
+                None => Response::Error { message: "no discernment".into() },
+            }
+        }
+
+        Request::CommandList { workspace } => {
+            let items: Vec<ProtoCommandInfo> = mgr
+                .list_commands(workspace)
+                .await
+                .into_iter()
+                .map(|c| ProtoCommandInfo {
+                    id: c.id,
+                    label: c.label,
+                    pid: c.pid,
+                    alive: c.alive,
+                    exit_status: c.exit_status,
+                    log_bytes: c.log_bytes,
+                })
+                .collect();
+            Response::CommandList { items }
+        }
+
+        Request::CommandLogs { workspace, command, tail_bytes } => {
+            match mgr.get_command_logs(workspace, command, tail_bytes).await {
+                Some(bytes) => Response::CommandLogs { bytes },
+                None => Response::Error {
+                    message: format!("no logs for command {command} in workspace {workspace}"),
+                },
+            }
+        }
+
+        Request::PipelineSave { name, spec } => {
+            mgr.save_pipeline(name.clone(), spec).await;
+            Response::PipelineSaved { name }
+        }
+
+        Request::PipelineSavedList => {
+            let names = mgr.list_saved_pipelines().await;
+            Response::PipelineSavedList { names }
+        }
+
+        Request::PipelineDrop { name } => {
+            let existed = mgr.drop_saved_pipeline(&name).await;
+            Response::PipelineDropped { name, existed }
+        }
+
+        Request::PipelineRunSaved { name, tap } => match mgr.get_saved_pipeline(&name).await {
+            Some(spec) => {
+                let disc = DiscernPipeline::default_pipeline();
+                let inc = mgr.incarnator_handle();
+                let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
+                match shipote_core::pipeline::run_pipeline(
+                    &spec,
+                    &ws_label,
+                    tap,
+                    std::sync::Arc::new(disc),
+                    inc,
+                )
+                .await
+                {
+                    Ok(launch) => Response::PipelineStarted {
+                        pipeline: launch.pipeline,
+                        command_pids: launch.command_pids,
+                        edges: launch
+                            .edge_discernments
+                            .into_iter()
+                            .map(|e| EdgeDiscernmentInfo {
+                                from_label: e.from_label,
+                                from_output: e.from_output,
+                                to_label: e.to_label,
+                                to_input: e.to_input,
+                                ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
+                                mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
+                                lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
+                                confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
+                            })
+                            .collect(),
+                    },
+                    Err(e) => Response::Error { message: format!("{e}") },
+                }
+            }
+            None => Response::Error {
+                message: format!("pipeline `{name}` no encontrado"),
+            },
+        },
+
+        Request::Capabilities => {
+            let c = mgr.incarnator().capabilities();
+            Response::Capabilities {
+                kernel_version: c.kernel_version,
+                user_ns: format!("{:?}", c.user_ns),
+                cgroup_v2: format!("{:?}", c.cgroup_v2),
+                cgroup_delegated: c.cgroup_delegated,
+                has_cap_sys_admin: c.has_cap_sys_admin,
+            }
+        }
+    }
+}
+
+/// Card del daemon. La presentamos al broker así otras sesiones pueden
+/// descubrir que shipote está corriendo y, eventualmente, conectarse
+/// como consumidoras del flow `workspaces` (futuro: que la GUI o el
+/// broker-explorer los listen vía broker en lugar de socket directo).
+fn build_daemon_card(service_socket: &std::path::Path) -> Card {
+    let mut card = Card::new("shipote.daemon");
+    card.kind = CardKind::Ente;
+    card.lifecycle = Lifecycle::Daemon;
+    card.payload = Payload::Virtual; // el daemon ya está corriendo (no es PID 1 quien lo encarna)
+    card.supervision = Supervision::Delegate;
+    card.service_socket = Some(service_socket.to_path_buf());
+    card.flow = Flows {
+        input: Vec::new(),
+        output: vec![
+            Flow {
+                name: "workspaces".into(),
+                ty: TypeRef::Wit {
+                    package: "shipote:admin".into(),
+                    interface: None,
+                    name: "workspace-list".into(),
+                },
+                pin_to: None,
+            },
+            Flow {
+                name: "discern".into(),
+                ty: TypeRef::Wit {
+                    package: "shipote:admin".into(),
+                    interface: None,
+                    name: "discernment".into(),
+                },
+                pin_to: None,
+            },
+        ],
+    };
+    card
+}
+
+fn init_tracing() {
+    use tracing_subscriber::{fmt, EnvFilter};
+    let filter = EnvFilter::try_from_env("SHIPOTE_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
+    fmt().with_env_filter(filter).init();
+}
diff --git a/crates/apps/shipote-shell/Cargo.toml b/crates/apps/shipote-shell/Cargo.toml
new file mode 100644
index 0000000..6eaa0cb
--- /dev/null
+++ b/crates/apps/shipote-shell/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "shipote-shell"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "GUI de shipote: vista de Workspaces+comandos+capabilities. Conecta al daemon vía shipote-protocol."
+
+[[bin]]
+name = "shipote-shell"
+path = "src/main.rs"
+
+[dependencies]
+shipote-card = { path = "../../modules/shipote/shipote-card" }
+shipote-protocol = { path = "../../modules/shipote/shipote-protocol" }
+yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
+yahweh-launcher = { path = "../../modules/ui_engine/libs/launcher" }
+yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" }
+yahweh-widget-stat-card = { path = "../../modules/ui_engine/widgets/stat-card" }
+yahweh-widget-app-header = { path = "../../modules/ui_engine/widgets/app-header" }
+tokio = { workspace = true }
+gpui = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/apps/shipote-shell/src/main.rs b/crates/apps/shipote-shell/src/main.rs
new file mode 100644
index 0000000..acd4706
--- /dev/null
+++ b/crates/apps/shipote-shell/src/main.rs
@@ -0,0 +1,363 @@
+//! `shipote-shell` — GUI dashboard del daemon shipote.
+//!
+//! Probe-style: conecta al socket del daemon cada 2s, pide
+//! capabilities + workspace-list y los muestra en cards.
+//! Si el daemon no está corriendo, marca DOWN.
+
+use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
+use shipote_protocol::{
+    default_socket_path, read_frame, write_frame, CommandInfo, Request, Response, WorkspaceSummary,
+};
+use std::path::PathBuf;
+use std::time::Duration;
+use tokio::net::UnixStream;
+use yahweh_launcher::launch_app;
+use yahweh_theme::Theme;
+use yahweh_widget_app_header::app_header;
+use yahweh_widget_banner::{banner_themed, Banner};
+use yahweh_widget_stat_card::stat_card;
+
+const POLL_INTERVAL: Duration = Duration::from_secs(2);
+
+#[derive(Clone, Debug)]
+enum DaemonState {
+    Pending,
+    Down { reason: String },
+    Up,
+}
+
+#[derive(Clone, Debug, Default)]
+struct CapsSummary {
+    kernel_version: (u32, u32, u32),
+    user_ns: String,
+    cgroup_v2: String,
+    cgroup_delegated: bool,
+    has_cap_sys_admin: bool,
+}
+
+struct Shell {
+    socket_path: PathBuf,
+    state: DaemonState,
+    workspaces: Vec<WorkspaceSummary>,
+    /// Comandos por workspace, indexados por workspace id.toString().
+    commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
+    saved_pipelines: Vec<String>,
+    caps: Option<CapsSummary>,
+    last_probe_ms: u64,
+}
+
+fn main() {
+    launch_app("Shipote — Shell", (820., 560.), Shell::new);
+}
+
+impl Shell {
+    fn new(cx: &mut Context<Self>) -> Self {
+        let socket_path = default_socket_path();
+        let socket_for_loop = socket_path.clone();
+        cx.spawn(async move |this, cx| {
+            let timer = cx.background_executor().clone();
+            let bg = cx.background_executor().clone();
+            loop {
+                let path = socket_for_loop.clone();
+                let started = std::time::Instant::now();
+                let result = bg
+                    .spawn(async move { probe_blocking(&path) })
+                    .await;
+                let elapsed = started.elapsed().as_millis() as u64;
+                let _ = this.update(cx, |me, cx| {
+                    match result {
+                        Ok(snap) => {
+                            me.state = DaemonState::Up;
+                            me.workspaces = snap.workspaces;
+                            me.commands = snap.commands;
+                            me.saved_pipelines = snap.saved_pipelines;
+                            me.caps = Some(snap.caps);
+                        }
+                        Err(reason) => {
+                            me.state = DaemonState::Down { reason };
+                            me.workspaces.clear();
+                            me.commands.clear();
+                            me.saved_pipelines.clear();
+                            me.caps = None;
+                        }
+                    }
+                    me.last_probe_ms = elapsed;
+                    cx.notify();
+                });
+                timer.timer(POLL_INTERVAL).await;
+            }
+        })
+        .detach();
+
+        Self {
+            socket_path,
+            state: DaemonState::Pending,
+            workspaces: Vec::new(),
+            commands: std::collections::BTreeMap::new(),
+            saved_pipelines: Vec::new(),
+            caps: None,
+            last_probe_ms: 0,
+        }
+    }
+}
+
+#[derive(Debug)]
+struct Snapshot {
+    workspaces: Vec<WorkspaceSummary>,
+    commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
+    saved_pipelines: Vec<String>,
+    caps: CapsSummary,
+}
+
+fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
+    // Mini tokio runtime efímero por probe — no compartimos runtime con
+    // GPUI. Costo aceptable cada 2s: setup ≈ <1 ms.
+    let rt = tokio::runtime::Builder::new_current_thread()
+        .enable_io()
+        .enable_time()
+        .build()
+        .map_err(|e| format!("rt: {e}"))?;
+    rt.block_on(async {
+        let mut stream = UnixStream::connect(path)
+            .await
+            .map_err(|e| format!("connect: {e}"))?;
+        write_frame(&mut stream, &Request::WorkspaceList)
+            .await
+            .map_err(|e| format!("write list: {e}"))?;
+        let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read list: {e}"))?;
+        let workspaces = match resp {
+            Response::WorkspaceList { items } => items,
+            other => return Err(format!("unexpected list resp: {other:?}")),
+        };
+
+        // Commands por workspace.
+        let mut commands_map = std::collections::BTreeMap::new();
+        for w in &workspaces {
+            write_frame(&mut stream, &Request::CommandList { workspace: w.id })
+                .await
+                .map_err(|e| format!("write commands: {e}"))?;
+            let resp: Response = read_frame(&mut stream)
+                .await
+                .map_err(|e| format!("read commands: {e}"))?;
+            if let Response::CommandList { items } = resp {
+                if !items.is_empty() {
+                    commands_map.insert(w.id.to_string(), items);
+                }
+            }
+        }
+
+        // Saved pipelines.
+        write_frame(&mut stream, &Request::PipelineSavedList)
+            .await
+            .map_err(|e| format!("write saved: {e}"))?;
+        let resp: Response = read_frame(&mut stream)
+            .await
+            .map_err(|e| format!("read saved: {e}"))?;
+        let saved_pipelines = match resp {
+            Response::PipelineSavedList { names } => names,
+            _ => Vec::new(),
+        };
+
+        write_frame(&mut stream, &Request::Capabilities)
+            .await
+            .map_err(|e| format!("write caps: {e}"))?;
+        let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read caps: {e}"))?;
+        let caps = match resp {
+            Response::Capabilities {
+                kernel_version,
+                user_ns,
+                cgroup_v2,
+                cgroup_delegated,
+                has_cap_sys_admin,
+            } => CapsSummary {
+                kernel_version,
+                user_ns,
+                cgroup_v2,
+                cgroup_delegated,
+                has_cap_sys_admin,
+            },
+            other => return Err(format!("unexpected caps resp: {other:?}")),
+        };
+        Ok(Snapshot {
+            workspaces,
+            commands: commands_map,
+            saved_pipelines,
+            caps,
+        })
+    })
+}
+
+impl Render for Shell {
+    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = Theme::global(cx).clone();
+        let bg = theme.bg_app.clone();
+        let text = theme.fg_text;
+        let text_dim = theme.fg_muted;
+
+        let accent_up = gpui::rgb(0xa3be8c);
+        let accent_down = gpui::rgb(0xbf616a);
+        let accent_pending = gpui::rgb(0x6a7280);
+        let accent_info = gpui::rgb(0x88c0d0);
+
+        let header_text = format!(
+            "Daemon: {}  ·  reload {} ms",
+            self.socket_path.display(),
+            self.last_probe_ms
+        );
+        let header = app_header(cx, header_text);
+
+        let status_banner = match &self.state {
+            DaemonState::Pending => None,
+            DaemonState::Down { reason } => Some(banner_themed(
+                cx,
+                Banner::Error,
+                SharedString::from(format!("Daemon DOWN — {reason}")),
+            )),
+            DaemonState::Up => Some(banner_themed(
+                cx,
+                Banner::Success,
+                SharedString::from("Daemon UP"),
+            )),
+        };
+
+        let (status_value, status_descr, status_accent) = match &self.state {
+            DaemonState::Pending => ("PENDING".to_string(), "primer probe…".to_string(), accent_pending),
+            DaemonState::Down { reason } => ("DOWN".to_string(), reason.clone(), accent_down),
+            DaemonState::Up => ("UP".to_string(), "shipote-daemon respondiendo".to_string(), accent_up),
+        };
+
+        let caps_items: Vec<String> = self
+            .caps
+            .as_ref()
+            .map(|c| {
+                vec![
+                    format!(
+                        "kernel:           {}.{}.{}",
+                        c.kernel_version.0, c.kernel_version.1, c.kernel_version.2
+                    ),
+                    format!("user_ns:          {}", c.user_ns),
+                    format!("cgroup_v2:        {}", c.cgroup_v2),
+                    format!("cgroup_delegated: {}", c.cgroup_delegated),
+                    format!("cap_sys_admin:    {}", c.has_cap_sys_admin),
+                ]
+            })
+            .unwrap_or_default();
+        let caps_value = if self.caps.is_some() { "OK".to_string() } else { "—".to_string() };
+
+        let ws_items: Vec<String> = self
+            .workspaces
+            .iter()
+            .map(|w| format!("{}  {:<20} cmds={} uptime={}ms", w.id, w.label, w.commands, w.uptime_ms))
+            .collect();
+        let ws_count = self.workspaces.len().to_string();
+        let ws_descr = if self.workspaces.is_empty() {
+            "no hay workspaces vivos".to_string()
+        } else {
+            "id · label · cmds · uptime".to_string()
+        };
+
+        // Comandos: aplanamos por workspace, tomamos los más recientes (orden ULID ya temporal).
+        let mut cmd_items: Vec<String> = Vec::new();
+        let mut cmd_total = 0usize;
+        for (ws_id, cmds) in &self.commands {
+            cmd_total += cmds.len();
+            for c in cmds.iter().rev().take(8) {
+                let alive = if c.alive { "▶" } else { "✓" };
+                let exit = c
+                    .exit_status
+                    .map(|s| format!(" exit={s}"))
+                    .unwrap_or_default();
+                cmd_items.push(format!(
+                    "{} {} {:<20} pid={} logs={}B{}",
+                    alive,
+                    &ws_id[..6.min(ws_id.len())],
+                    c.label,
+                    c.pid,
+                    c.log_bytes,
+                    exit
+                ));
+            }
+        }
+        let cmd_count = cmd_total.to_string();
+        let cmd_descr = if cmd_total == 0 {
+            "no hay comandos lanzados".to_string()
+        } else {
+            "▶=alive ✓=exited · ws_prefix · label · pid · logs".to_string()
+        };
+
+        // Saved pipelines.
+        let saved_count = self.saved_pipelines.len().to_string();
+        let saved_items: Vec<String> = self.saved_pipelines.clone();
+        let saved_descr = if saved_items.is_empty() {
+            "shipote pipeline save <name> <file> para persistir".to_string()
+        } else {
+            "definiciones reusables vía run-saved".to_string()
+        };
+
+        let body = div()
+            .flex()
+            .flex_col()
+            .gap(px(8.))
+            .px(px(16.))
+            .py(px(16.))
+            .child(stat_card(
+                cx,
+                "Estado",
+                status_value,
+                &status_descr,
+                status_accent,
+                text,
+                text_dim,
+                &[],
+            ))
+            .child(stat_card(
+                cx,
+                "Capabilities",
+                caps_value,
+                "kernel + namespaces + cgroup delegation",
+                accent_info,
+                text,
+                text_dim,
+                &caps_items,
+            ))
+            .child(stat_card(
+                cx,
+                "Workspaces",
+                ws_count,
+                &ws_descr,
+                accent_info,
+                text,
+                text_dim,
+                &ws_items,
+            ))
+            .child(stat_card(
+                cx,
+                "Comandos",
+                cmd_count,
+                &cmd_descr,
+                accent_info,
+                text,
+                text_dim,
+                &cmd_items,
+            ))
+            .child(stat_card(
+                cx,
+                "Saved pipelines",
+                saved_count,
+                &saved_descr,
+                accent_info,
+                text,
+                text_dim,
+                &saved_items,
+            ));
+
+        div()
+            .flex()
+            .flex_col()
+            .size_full()
+            .bg(bg)
+            .child(header)
+            .when_some(status_banner, |d, b| d.child(b))
+            .child(body)
+    }
+}
diff --git a/crates/apps/text_viewer/src/lib.rs b/crates/apps/text_viewer/src/lib.rs
index 135b40f..070bb94 100644
--- a/crates/apps/text_viewer/src/lib.rs
+++ b/crates/apps/text_viewer/src/lib.rs
@@ -114,7 +114,7 @@ impl TextViewer {
     }
 
     fn spawn_load_fs(&self, path: String, gen: u64, cx: &mut Context<Self>) {
-        let provider = Arc::new(FileDataProvider);
+        let provider = Arc::new(FileDataProvider::new());
         cx.spawn(async move |this, cx| {
             let result = provider.get_data(&path).await;
             let _ = this.update(cx, |this, cx| this.on_loaded(gen, result, cx));
diff --git a/crates/core/ente-soma/Cargo.toml b/crates/core/ente-soma/Cargo.toml
index 63df673..78d24a5 100644
--- a/crates/core/ente-soma/Cargo.toml
+++ b/crates/core/ente-soma/Cargo.toml
@@ -4,11 +4,12 @@ version = "0.0.1"
 edition.workspace = true
 license.workspace = true
 publish.workspace = true
+description = "Wrapper histórico sobre ente-incarnate para mantener la API set_bus_sock+incarnate que usa ente-zero. Toda la lógica vive en ente-incarnate."
 
 [dependencies]
 ente-card = { path = "../ente-card" }
 ente-bus = { path = "../ente-bus" }
+ente-incarnate = { path = "../../shared/ente-incarnate" }
 nix = { workspace = true }
-libc = { workspace = true }
 anyhow = { workspace = true }
 tracing = { workspace = true }
diff --git a/crates/core/ente-soma/src/lib.rs b/crates/core/ente-soma/src/lib.rs
index e64ca01..c94f0d3 100644
--- a/crates/core/ente-soma/src/lib.rs
+++ b/crates/core/ente-soma/src/lib.rs
@@ -1,362 +1,44 @@
-//! Encarnación del Soma: traducción de SomaSpec a syscalls.
+//! `ente-soma` — wrapper histórico sobre [`ente_incarnate`].
 //!
-//! Esta capa es la única parte de PID 1 que toca syscalls de namespacing —
-//! todo lo demás opera sobre tipos de alto nivel. La complejidad vive aquí
-//! por diseño: encapsulada, auditable, y con un único punto de entrada.
+//! La rutina de namespacing fue extraída a `ente-incarnate` para que
+//! shipote, exploradores y cualquier supervisor no-PID-1 puedan reusarla.
+//! Este crate sobrevive como compat para `ente-zero` y otros que importan
+//! `ente_soma::{set_bus_sock, incarnate}`.
 //!
-//! ## Protocolo padre↔hijo en el path namespaced
-//!
-//! ```text
-//!   parent               child
-//!     |                    |
-//!     |--- clone() ------->|   (child empieza dentro de los nuevos NS)
-//!     |                    |
-//!     |                    |---- read(sync_r, 1) ----  (bloquea)
-//!     |                    |
-//!     |  write uid_map    |
-//!     |  write gid_map    |
-//!     |  cgroup move       |
-//!     |  cpu affinity      |
-//!     |                    |
-//!     |--- write(sync_w) ->|
-//!     |                    |---- setrlimit
-//!     |                    |---- mount(/, MS_PRIVATE | MS_REC)
-//!     |                    |---- execve()
-//! ```
-
-use ente_card::{CgroupSpec, EntityCard, NamespaceSet, Payload, ResourceLimits};
-use nix::fcntl::OFlag;
-use nix::sched::CloneFlags;
-use nix::unistd::{pipe2, Pid};
-use std::ffi::CString;
-use std::os::fd::{AsRawFd, IntoRawFd, RawFd};
-use std::process::Command;
+//! Semántica preservada:
+//! - `BUS_SOCK_PATH` global vía `OnceLock` (init lo setea una vez).
+//! - `NOTIFY_SOCKET=/run/systemd/notify` se inyecta automáticamente.
+//! - `strict_caps = false` (errores no-fatales se loguean, encarnación sigue).
+
+use ente_card::EntityCard;
+use ente_incarnate::{Incarnator, IncarnatorConfig};
+use nix::unistd::Pid;
+use std::path::PathBuf;
 use std::sync::OnceLock;
-use tracing::{info, warn};
+use tracing::warn;
 
-/// Path del socket del bus interno. Se establece una sola vez al arrancar
-/// PID 1 (después de que el listener bind exitoso). Cada hijo encarnado
-/// recibe este path en `ENTE_BUS_SOCK`.
-static BUS_SOCK_PATH: OnceLock<String> = OnceLock::new();
+static INCARNATOR: OnceLock<Incarnator> = OnceLock::new();
 
+/// Establece el path del socket del bus interno. Se llama una sola vez al
+/// arrancar PID 1 (después de que el listener bind exitoso). Cada hijo
+/// encarnado recibirá este path en `ENTE_BUS_SOCK`.
 pub fn set_bus_sock(path: String) {
-    let _ = BUS_SOCK_PATH.set(path);
-}
-
-fn build_env(card: &EntityCard, base_envp: &[(String, String)]) -> Vec<(String, String)> {
-    // Heredamos parent env, sobreescribimos con el envp explícito de la Card,
-    // y al final inyectamos las vars del fractal (no negociables).
-    let mut env: Vec<(String, String)> = std::env::vars().collect();
-    for (k, v) in base_envp {
-        env.retain(|(ek, _)| ek != k);
-        env.push((k.clone(), v.clone()));
-    }
-    if let Some(p) = BUS_SOCK_PATH.get() {
-        env.retain(|(k, _)| k != ente_bus::ENV_BUS_SOCK);
-        env.push((ente_bus::ENV_BUS_SOCK.into(), p.clone()));
-    }
-    env.retain(|(k, _)| k != ente_bus::ENV_ENTE_ID);
-    env.push((ente_bus::ENV_ENTE_ID.into(), card.id.to_string()));
-    // Apps `Type=notify` (sd_notify) leen NOTIFY_SOCKET. Apuntamos al path
-    // canónico de systemd; si ente-notify-compat no está corriendo, apps
-    // sólo verán que sd_notify falla y siguen sin "ready" signal — no es fatal.
-    env.retain(|(k, _)| k != "NOTIFY_SOCKET");
-    env.push(("NOTIFY_SOCKET".into(), "/run/systemd/notify".into()));
-    env
-}
-
-pub fn incarnate(card: &EntityCard) -> anyhow::Result<Pid> {
-    if needs_namespacing(&card.soma.namespaces) {
-        incarnate_namespaced(card)
-    } else {
-        incarnate_plain(card)
-    }
-}
-
-fn needs_namespacing(ns: &NamespaceSet) -> bool {
-    ns.mount || ns.pid || ns.net || ns.uts || ns.ipc || ns.user || ns.cgroup
-}
-
-/// Path simple: para Entes que no requieren aislamiento. Útil para Entes-shim
-/// que conviven con el host (e.g. compat-logind) y para dev mode.
-fn incarnate_plain(card: &EntityCard) -> anyhow::Result<Pid> {
-    let (exec, argv, base_envp) = match &card.payload {
-        Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()),
-        Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()),
-        _ => anyhow::bail!("incarnate_plain: payload no ejecutable"),
-    };
-    let env = build_env(card, &base_envp);
-    let mut cmd = Command::new(&exec);
-    cmd.args(&argv);
-    cmd.env_clear();
-    for (k, v) in &env {
-        cmd.env(k, v);
-    }
-    let child = cmd.spawn().map_err(|e| anyhow::anyhow!("spawn {exec}: {e}"))?;
-    Ok(Pid::from_raw(child.id() as i32))
-}
-
-/// Path namespaced: clone(2) + sync pipe + setup post-clone en padre + finalize en hijo.
-fn incarnate_namespaced(card: &EntityCard) -> anyhow::Result<Pid> {
-    let flags = build_clone_flags(&card.soma.namespaces);
-    info!(label = %card.label, ?flags, "namespaced incarnation");
-
-    let (exec, argv, base_envp) = match &card.payload {
-        Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()),
-        Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()),
-        _ => anyhow::bail!("incarnate_namespaced: payload no ejecutable"),
-    };
-
-    // Pipe O_CLOEXEC: el read del lado hijo es lo que hace race-free el setup.
-    // O_CLOEXEC garantiza que el fd se cierra automáticamente en execve, así
-    // no contamina el binario final.
-    let (sync_r, sync_w) = pipe2(OFlag::O_CLOEXEC)?;
-    let sync_r_raw: RawFd = sync_r.into_raw_fd();
-    let sync_w_raw: RawFd = sync_w.into_raw_fd();
-
-    let exec_c = CString::new(exec.clone())?;
-    let argv_c: Vec<CString> = std::iter::once(exec_c.clone())
-        .chain(argv.iter().filter_map(|s| CString::new(s.as_str()).ok()))
-        .collect();
-    let argv_ptrs: Vec<*const libc::c_char> = argv_c.iter()
-        .map(|c| c.as_ptr())
-        .chain(std::iter::once(std::ptr::null()))
-        .collect();
-
-    // envp construido pre-clone: padre y hijo comparten el COW. Tras execve
-    // el kernel reemplaza el address space, así que las CStrings sólo viven
-    // hasta el syscall.
-    let env_pairs = build_env(card, &base_envp);
-    let envp_c: Vec<CString> = env_pairs.iter()
-        .filter_map(|(k, v)| CString::new(format!("{k}={v}")).ok())
-        .collect();
-    let envp_ptrs: Vec<*const libc::c_char> = envp_c.iter()
-        .map(|c| c.as_ptr())
-        .chain(std::iter::once(std::ptr::null()))
-        .collect();
-
-    let rlimits = card.soma.rlimits.clone();
-    let mount_ns_enabled = card.soma.namespaces.mount;
-
-    // SAFETY: la clausura corre en stack nuevo dentro de un proceso recién
-    // clonado, COW del padre. Reglas inviolables:
-    //   - sólo syscalls async-signal-safe
-    //   - no `println!`/`tracing!`/cualquier I/O del runtime
-    //   - no allocator (vec/box/string)
-    //   - no Drop con efectos
-    //   - capturar sólo Copy o datos pre-construidos
-    let cb = Box::new(move || -> isize {
-        // 1) Cerrar el extremo de escritura: pertenece al padre.
-        unsafe { libc::close(sync_w_raw); }
-
-        // 2) Bloquear hasta que el padre termine el setup (uid_map, cgroup, etc).
-        let mut byte = [0u8; 1];
-        let n = unsafe {
-            libc::read(sync_r_raw, byte.as_mut_ptr() as *mut _, 1)
-        };
-        if n != 1 { unsafe { libc::_exit(101); } }
-        unsafe { libc::close(sync_r_raw); }
-
-        // 3) Aplicar rlimits dentro del nuevo namespace.
-        unsafe { apply_rlimits_unchecked(&rlimits); }
-
-        // 4) Si tenemos mount ns, marcar / como privado recursivamente para
-        //    que mounts del Ente no se filtren al host (es la trampa más
-        //    típica al delegar mount ns).
-        if mount_ns_enabled {
-            unsafe {
-                libc::mount(
-                    std::ptr::null(),
-                    b"/\0".as_ptr() as *const _,
-                    std::ptr::null(),
-                    libc::MS_PRIVATE | libc::MS_REC,
-                    std::ptr::null(),
-                );
-            }
-        }
-
-        // 5) execve. Si retorna, falló.
-        unsafe {
-            libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr());
-            libc::_exit(102);
-        }
-    });
-
-    let mut stack = vec![0u8; 1024 * 1024];
-
-    #[allow(deprecated)]
-    let pid = unsafe {
-        nix::sched::clone(cb, &mut stack, flags, Some(libc::SIGCHLD))
-    }.map_err(|e| {
-        unsafe { libc::close(sync_r_raw); libc::close(sync_w_raw); }
-        anyhow::anyhow!("clone failed: {e}")
-    })?;
-
-    // Padre: cerrar el extremo de lectura.
-    unsafe { libc::close(sync_r_raw); }
-
-    // Setup post-clone en padre. Errores aquí no son fatales — registramos y
-    // continuamos. Si algo crítico falla, el hijo execve seguirá adelante con
-    // configuración degradada y el supervisor decidirá qué hacer.
-    if let Err(e) = configure_child(pid, card) {
-        warn!(?e, ?pid, "configure_child errores no-fatales");
-    }
-
-    // Despertar al hijo.
-    let signal_byte = [b'x'];
-    let written = unsafe {
-        libc::write(sync_w_raw, signal_byte.as_ptr() as *const _, 1)
+    let cfg = IncarnatorConfig {
+        bus_sock: Some(PathBuf::from(path)),
+        notify_socket: Some(PathBuf::from("/run/systemd/notify")),
+        extra_env: Vec::new(),
+        strict_caps: false,
     };
-    unsafe { libc::close(sync_w_raw); }
-    if written != 1 {
-        warn!(?pid, "no se pudo señalizar al hijo (write devolvió {})", written);
-    }
-
-    if matches!(&card.payload, Payload::Legacy { fakes, .. } if !fakes.is_empty()) {
-        // TODO: facades viven en un Ente-shim aparte que se inyecta vía
-        // bind-mount sobre /run/systemd/notify, /run/dbus/system_bus_socket,
-        // etc. Cuando exista, registrarlas aquí.
-        warn!("legacy facades declaradas pero shim post-clone no implementado");
-    }
-
-    Ok(pid)
-}
-
-/// Setup que requiere capacidades del padre: uid_map, gid_map, cgroup move.
-/// Estos archivos en /proc/<pid>/* tienen reglas de propiedad que sólo el
-/// padre puede satisfacer mientras el hijo está suspendido en el sync pipe.
-fn configure_child(pid: Pid, card: &EntityCard) -> anyhow::Result<()> {
-    if card.soma.namespaces.user {
-        // Desde kernel 3.19 se debe escribir "deny" a setgroups antes de
-        // poder escribir gid_map sin CAP_SETGID. Ignorar errores: en kernels
-        // antiguos el archivo no existe y no es problema.
-        let _ = std::fs::write(format!("/proc/{}/setgroups", pid.as_raw()), "deny");
-
-        let uid = nix::unistd::getuid().as_raw();
-        let gid = nix::unistd::getgid().as_raw();
-        std::fs::write(
-            format!("/proc/{}/uid_map", pid.as_raw()),
-            format!("0 {uid} 1"),
-        ).map_err(|e| anyhow::anyhow!("write uid_map: {e}"))?;
-        std::fs::write(
-            format!("/proc/{}/gid_map", pid.as_raw()),
-            format!("0 {gid} 1"),
-        ).map_err(|e| anyhow::anyhow!("write gid_map: {e}"))?;
-    }
-
-    if !card.soma.cgroup.path.is_empty() {
-        match ensure_cgroup(&card.soma.cgroup) {
-            Ok(abs_path) => {
-                let procs = format!("{abs_path}/cgroup.procs");
-                if let Err(e) = std::fs::write(&procs, format!("{}\n", pid.as_raw())) {
-                    warn!(?e, path = %procs, "cgroup move falló");
-                }
-            }
-            Err(e) => warn!(?e, path = %card.soma.cgroup.path, "ensure_cgroup falló"),
-        }
-    }
-
-    if let Some(cpus) = &card.soma.cpu_affinity {
-        if let Err(e) = set_cpu_affinity(pid, cpus) {
-            warn!(?e, ?pid, "sched_setaffinity falló");
-        }
-    }
-
-    Ok(())
-}
-
-fn set_cpu_affinity(pid: Pid, cpus: &[u32]) -> anyhow::Result<()> {
-    let mut set: libc::cpu_set_t = unsafe { std::mem::zeroed() };
-    unsafe { libc::CPU_ZERO(&mut set); }
-    for &c in cpus {
-        unsafe { libc::CPU_SET(c as usize, &mut set); }
-    }
-    let r = unsafe {
-        libc::sched_setaffinity(pid.as_raw(), std::mem::size_of::<libc::cpu_set_t>(), &set)
-    };
-    if r != 0 {
-        anyhow::bail!("sched_setaffinity: {}", std::io::Error::last_os_error());
-    }
-    Ok(())
+    let _ = INCARNATOR.set(Incarnator::new(cfg));
 }
 
-/// SAFETY: invocada en el hijo post-clone, sólo libc, no Rust I/O.
-unsafe fn apply_rlimits_unchecked(rl: &ResourceLimits) {
-    if let Some(mem) = rl.mem_bytes {
-        let lim = libc::rlimit { rlim_cur: mem, rlim_max: mem };
-        libc::setrlimit(libc::RLIMIT_AS, &lim);
-    }
-    if let Some(np) = rl.nproc {
-        let lim = libc::rlimit { rlim_cur: np as u64, rlim_max: np as u64 };
-        libc::setrlimit(libc::RLIMIT_NPROC, &lim);
-    }
-    if let Some(nf) = rl.nofile {
-        let lim = libc::rlimit { rlim_cur: nf as u64, rlim_max: nf as u64 };
-        libc::setrlimit(libc::RLIMIT_NOFILE, &lim);
-    }
-}
-
-/// Cgroup actual del proceso PID 1 (o ente-zero en dev). Lo usamos como
-/// prefijo para paths declarados relativos en CgroupSpec.path. En prod (PID 1
-/// como child del kernel) será `/`. En dev bajo systemd-user será algo como
-/// `/user.slice/user-1001.slice/user@1001.service/...`.
-fn current_cgroup() -> Option<String> {
-    let s = std::fs::read_to_string("/proc/self/cgroup").ok()?;
-    // Formato unified (cgroup v2): "0::/user.slice/..."
-    s.lines()
-        .find_map(|l| l.strip_prefix("0::"))
-        .map(|s| s.trim().to_string())
-}
-
-/// Resuelve un path declarado en CgroupSpec contra la jerarquía real.
-/// - path absoluto (empieza con `/`): respetar tal cual
-/// - path relativo: prefijar con cgroup actual de PID 1
-fn resolve_cgroup_path(spec_path: &str) -> String {
-    if spec_path.is_empty() { return String::new(); }
-    if spec_path.starts_with('/') {
-        return spec_path.to_string();
-    }
-    let trimmed = spec_path.trim_start_matches('/');
-    if let Some(cg) = current_cgroup() {
-        let base = if cg == "/" { String::new() } else { cg.trim_end_matches('/').to_string() };
-        format!("{base}/{trimmed}")
-    } else {
-        format!("/{trimmed}")
-    }
-}
-
-/// Crea el cgroup declarado, aplica weights. Devuelve el path absoluto
-/// resultante bajo /sys/fs/cgroup.
-fn ensure_cgroup(spec: &CgroupSpec) -> anyhow::Result<String> {
-    let rel = resolve_cgroup_path(&spec.path);
-    if rel.is_empty() {
-        anyhow::bail!("cgroup path vacío");
-    }
-    let abs = format!("/sys/fs/cgroup{}", rel);
-    std::fs::create_dir_all(&abs)
-        .map_err(|e| anyhow::anyhow!("mkdir {}: {e}", abs))?;
-    if let Some(w) = spec.cpu_weight {
-        let _ = std::fs::write(format!("{abs}/cpu.weight"), format!("{w}\n"));
-    }
-    if let Some(w) = spec.io_weight {
-        // io.weight requiere el formato "default <n>" en cgroup v2.
-        let _ = std::fs::write(format!("{abs}/io.weight"), format!("default {w}\n"));
+/// Encarna un EntityCard. Si `set_bus_sock` no fue invocado todavía,
+/// usa un Incarnator default (sin bus, sin notify).
+pub fn incarnate(card: &EntityCard) -> anyhow::Result<Pid> {
+    let inc = INCARNATOR.get_or_init(|| Incarnator::new(IncarnatorConfig::default()));
+    let out = inc.incarnate(card)?;
+    for d in &out.degradations {
+        warn!(?d, ?out.pid, "incarnation degradation");
     }
-    Ok(abs)
-}
-
-fn build_clone_flags(ns: &NamespaceSet) -> CloneFlags {
-    let mut f = CloneFlags::empty();
-    if ns.mount  { f |= CloneFlags::CLONE_NEWNS; }
-    if ns.pid    { f |= CloneFlags::CLONE_NEWPID; }
-    if ns.net    { f |= CloneFlags::CLONE_NEWNET; }
-    if ns.uts    { f |= CloneFlags::CLONE_NEWUTS; }
-    if ns.ipc    { f |= CloneFlags::CLONE_NEWIPC; }
-    if ns.user   { f |= CloneFlags::CLONE_NEWUSER; }
-    if ns.cgroup { f |= CloneFlags::CLONE_NEWCGROUP; }
-    f
+    Ok(out.pid)
 }
-
-// AsRawFd unused but keep the import alive — soma may grow more fd handling.
-#[allow(dead_code)]
-fn _keep_imports(_: &dyn AsRawFd) {}
diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml
index b752946..fe18c1a 100644
--- a/crates/modules/nouser/core/Cargo.toml
+++ b/crates/modules/nouser/core/Cargo.toml
@@ -11,6 +11,7 @@ description = "Nouser — explorador de Mónadas: scanner, clustering determinis
 [dependencies]
 nouser-card = { path = "../card" }
 nouser-nous = { path = "../nous" }
+shipote-discern = { path = "../../shipote/shipote-discern" }
 brahman-card = { path = "../../../core/brahman-card" }
 brahman-handshake = { path = "../../../core/brahman-handshake" }
 brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
diff --git a/crates/modules/nouser/core/src/cluster.rs b/crates/modules/nouser/core/src/cluster.rs
index 7091ee3..5ea49c4 100644
--- a/crates/modules/nouser/core/src/cluster.rs
+++ b/crates/modules/nouser/core/src/cluster.rs
@@ -152,10 +152,12 @@ fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
     sorted.into_iter().take(n).map(|(k, _)| k).collect()
 }
 
-/// Elige el lente dominante según la extensión más frecuente.
+/// Elige el lente dominante según la extensión más frecuente, con
+/// fallback a `shipote-discern` sobre el head del archivo más
+/// representativo cuando la extensión no da hint claro (Lens::Grid).
 fn pick_lens(files: &[&FileEntry]) -> Lens {
     let dominant = top_extensions(files, 1).into_iter().next();
-    match dominant.as_deref() {
+    let by_ext = match dominant.as_deref() {
         Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
         | "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
         Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
@@ -164,6 +166,42 @@ fn pick_lens(files: &[&FileEntry]) -> Lens {
         Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
         Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
         _ => Lens::Grid,
+    };
+    if by_ext != Lens::Grid {
+        return by_ext;
+    }
+    // Fallback: samplear el primer archivo del grupo con shipote-discern.
+    // Sólo si tiene path real (FileEntry con path absoluto/relativo).
+    if let Some(first) = files.first() {
+        if let Some(lens) = discern_lens(&first.path) {
+            return lens;
+        }
+    }
+    Lens::Grid
+}
+
+fn discern_lens(path: &std::path::Path) -> Option<Lens> {
+    use std::io::Read;
+    let mut buf = vec![0u8; 4096];
+    let mut f = std::fs::File::open(path).ok()?;
+    let n = f.read(&mut buf).ok()?;
+    buf.truncate(n);
+    let pipeline = shipote_discern::DiscernPipeline::default_pipeline();
+    let path_str = path.to_str();
+    let d = pipeline.discern(
+        &buf,
+        &shipote_discern::Hint {
+            path: path_str,
+            size_total: None,
+        },
+    )?;
+    match d.lens.as_deref()? {
+        "code" => Some(Lens::Code),
+        "gallery" => Some(Lens::Gallery),
+        "markdown" => Some(Lens::Markdown),
+        "database" => Some(Lens::Database),
+        "tree" => Some(Lens::Tree),
+        _ => None,
     }
 }
 
diff --git a/crates/modules/shipote/shipote-card/Cargo.toml b/crates/modules/shipote/shipote-card/Cargo.toml
new file mode 100644
index 0000000..fa123e4
--- /dev/null
+++ b/crates/modules/shipote/shipote-card/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "shipote-card"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Tipos de shipote: WorkspaceSpec, PipelineSpec, CommandRef, FlowEdge. Compilan a Cards de brahman-card."
+
+[dependencies]
+brahman-card = { path = "../../../core/brahman-card" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+toml = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/modules/shipote/shipote-card/src/lib.rs b/crates/modules/shipote/shipote-card/src/lib.rs
new file mode 100644
index 0000000..110c38f
--- /dev/null
+++ b/crates/modules/shipote/shipote-card/src/lib.rs
@@ -0,0 +1,449 @@
+//! `shipote-card` — tipos del runtime shipote.
+//!
+//! Tres entidades nuevas encima del `brahman-card::Card`:
+//!
+//! - [`WorkspaceSpec`] — espacio aislado raíz con su propio `SomaSpec`.
+//! - [`CommandRef`] — un comando dentro de un workspace.
+//! - [`PipelineSpec`] — DAG de `CommandRef` conectados por `FlowEdge`.
+//!
+//! Cada `WorkspaceSpec`/`CommandRef` se **compila** a una o varias
+//! [`brahman_card::Card`] que el daemon entrega al [`Incarnator`] de
+//! `ente-incarnate`. Esto preserva el contrato canónico del fractal.
+
+#![forbid(unsafe_code)]
+
+use brahman_card::{Card, Payload, Permissions, SomaSpec, Supervision};
+use serde::{Deserialize, Serialize};
+use std::time::Duration;
+use thiserror::Error;
+use ulid::Ulid;
+
+// =====================================================================
+// Identidades
+// =====================================================================
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct WorkspaceId(pub Ulid);
+
+impl WorkspaceId {
+    pub fn new() -> Self {
+        Self(Ulid::new())
+    }
+}
+
+impl Default for WorkspaceId {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl std::fmt::Display for WorkspaceId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct PipelineId(pub Ulid);
+
+impl PipelineId {
+    pub fn new() -> Self {
+        Self(Ulid::new())
+    }
+}
+
+impl Default for PipelineId {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl std::fmt::Display for PipelineId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+// =====================================================================
+// Workspace
+// =====================================================================
+
+/// Espacio aislado de shipote. Es la raíz de aislamiento — cualquier comando
+/// que corre dentro hereda restricciones y no puede aflojarlas.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WorkspaceSpec {
+    pub label: String,
+
+    /// Aislamiento del workspace mismo (cuando se materializa como Card raíz).
+    #[serde(default)]
+    pub soma: SomaSpec,
+
+    /// Permisos máximos para hijas. Hijas pueden bajar pero no subir.
+    #[serde(default)]
+    pub permissions: Permissions,
+
+    /// `None` = vive hasta `stop`. `Some(d)` = el daemon lo termina tras d.
+    #[serde(default, with = "opt_duration_millis")]
+    pub ttl: Option<Duration>,
+
+    /// Slots de flow pre-declarados. Limitan qué consumidores externos al
+    /// workspace pueden empatar contra los productores internos.
+    #[serde(default)]
+    pub flow_dirs: Vec<FlowSlot>,
+
+    /// Política al terminar el workspace.
+    #[serde(default)]
+    pub on_exit: ExitPolicy,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FlowSlot {
+    pub name: String,
+    pub direction: FlowDirection,
+    /// Si `Workspace`, sólo otros nodos del mismo workspace pueden empatar.
+    /// Si `Public`, el broker global puede emparejar.
+    #[serde(default)]
+    pub scope: FlowScope,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FlowDirection {
+    Input,
+    Output,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FlowScope {
+    #[default]
+    Workspace,
+    Public,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ExitPolicy {
+    /// Reapear procesos hijos y descartar estado.
+    #[default]
+    Reap,
+    /// Mantener el workspace en `stopped` para inspección.
+    Keep,
+    /// Tomar snapshot del estado (para restart posterior).
+    Snapshot,
+}
+
+mod opt_duration_millis {
+    use serde::{Deserialize, Deserializer, Serialize, Serializer};
+    use std::time::Duration;
+
+    pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
+        d.map(|x| x.as_millis() as u64).serialize(s)
+    }
+
+    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
+        let v: Option<u64> = Option::deserialize(d)?;
+        Ok(v.map(Duration::from_millis))
+    }
+}
+
+// =====================================================================
+// CommandRef
+// =====================================================================
+
+/// Un comando que vive dentro de un workspace. Se compila a una `Card` con
+/// `pin_to` apuntando al workspace padre (label) y su `SomaSpec`
+/// intersectado con el del workspace.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CommandRef {
+    pub label: String,
+    pub payload: Payload,
+
+    /// SomaSpec del comando. El compilador lo intersecta con el del workspace.
+    #[serde(default)]
+    pub soma: SomaSpec,
+
+    /// Inputs/outputs tipados (mismos `Flow` de brahman-card).
+    #[serde(default)]
+    pub flows: brahman_card::Flows,
+
+    /// Política de supervisión. Default `OneShot` (un comando se ejecuta y muere).
+    #[serde(default = "default_oneshot")]
+    pub supervision: Supervision,
+}
+
+fn default_oneshot() -> Supervision {
+    Supervision::OneShot
+}
+
+// =====================================================================
+// Pipeline
+// =====================================================================
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PipelineSpec {
+    pub label: String,
+    pub workspace: WorkspaceId,
+    pub nodes: Vec<CommandRef>,
+    #[serde(default)]
+    pub edges: Vec<FlowEdge>,
+    #[serde(default)]
+    pub discern: DiscernPolicy,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FlowEdge {
+    /// Índice en `PipelineSpec.nodes` del productor.
+    pub from: usize,
+    /// Nombre del Flow output del productor.
+    pub from_output: String,
+    /// Índice en `PipelineSpec.nodes` del consumidor.
+    pub to: usize,
+    /// Nombre del Flow input del consumidor.
+    pub to_input: String,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct DiscernPolicy {
+    /// Bytes a samplear por flow para el discernidor. Default 4 KiB.
+    #[serde(default = "default_sample_bytes")]
+    pub sample_bytes: usize,
+    /// Si `true`, enriquece la Card del producer con el TypeRef detectado.
+    #[serde(default = "default_true")]
+    pub enrich_producer: bool,
+}
+
+fn default_sample_bytes() -> usize {
+    4096
+}
+fn default_true() -> bool {
+    true
+}
+
+// =====================================================================
+// Compilación a Card
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum CompileError {
+    #[error("workspace label vacío")]
+    EmptyWorkspaceLabel,
+    #[error("comando con label vacío en posición {0}")]
+    EmptyCommandLabel(usize),
+    #[error("edge fuera de rango: from={from}, to={to}, nodes={nodes}")]
+    EdgeOutOfBounds { from: usize, to: usize, nodes: usize },
+}
+
+impl WorkspaceSpec {
+    /// Compila el WorkspaceSpec a una Card raíz que el Incarnator puede
+    /// encarnar. Usa `Payload::Virtual` (el workspace no es un proceso por
+    /// sí solo; sólo aloja hijos).
+    pub fn to_card(&self, id: WorkspaceId) -> Result<Card, CompileError> {
+        if self.label.trim().is_empty() {
+            return Err(CompileError::EmptyWorkspaceLabel);
+        }
+        let mut c = Card::new(format!("shipote.workspace.{}", self.label));
+        c.id = id.0;
+        c.soma = self.soma.clone();
+        c.permissions = self.permissions.clone();
+        c.payload = Payload::Virtual;
+        c.supervision = Supervision::OneShot;
+        Ok(c)
+    }
+}
+
+impl CommandRef {
+    /// Compila un CommandRef a Card hija de un workspace. La Card resultante
+    /// referencia al workspace por label en `pin_to` de cada Flow.
+    pub fn to_card(&self, idx: usize, workspace_label: &str) -> Result<Card, CompileError> {
+        if self.label.trim().is_empty() {
+            return Err(CompileError::EmptyCommandLabel(idx));
+        }
+        let mut c = Card::new(format!("shipote.cmd.{}.{}", workspace_label, self.label));
+        c.payload = self.payload.clone();
+        c.soma = intersect_soma(&self.soma, /*workspace*/ &SomaSpec::default());
+        c.supervision = self.supervision.clone();
+        c.flow = self.flows.clone();
+        // pin_to del workspace en cada Flow input/output → el broker prefiere
+        // resolver dentro del mismo workspace cuando hay candidatos múltiples.
+        let pin = format!("shipote.workspace.{}", workspace_label);
+        for f in c.flow.input.iter_mut().chain(c.flow.output.iter_mut()) {
+            if f.pin_to.is_none() {
+                f.pin_to = Some(pin.clone());
+            }
+        }
+        Ok(c)
+    }
+}
+
+/// Intersección conservadora: si el workspace pidió aislamiento, la hija
+/// también lo tiene (no puede aflojar). Si la hija pidió aislamiento extra,
+/// se respeta.
+fn intersect_soma(child: &SomaSpec, ws: &SomaSpec) -> SomaSpec {
+    let mut out = child.clone();
+    out.namespaces.mount  |= ws.namespaces.mount;
+    out.namespaces.pid    |= ws.namespaces.pid;
+    out.namespaces.net    |= ws.namespaces.net;
+    out.namespaces.uts    |= ws.namespaces.uts;
+    out.namespaces.ipc    |= ws.namespaces.ipc;
+    out.namespaces.user   |= ws.namespaces.user;
+    out.namespaces.cgroup |= ws.namespaces.cgroup;
+    // rlimits: el menor (más restrictivo) gana.
+    out.rlimits.mem_bytes = min_opt(out.rlimits.mem_bytes, ws.rlimits.mem_bytes);
+    out.rlimits.nproc = min_opt(out.rlimits.nproc, ws.rlimits.nproc);
+    out.rlimits.nofile = min_opt(out.rlimits.nofile, ws.rlimits.nofile);
+    out
+}
+
+fn min_opt<T: Ord + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
+    match (a, b) {
+        (Some(x), Some(y)) => Some(x.min(y)),
+        (Some(x), None) | (None, Some(x)) => Some(x),
+        (None, None) => None,
+    }
+}
+
+impl PipelineSpec {
+    pub fn validate(&self) -> Result<(), CompileError> {
+        let n = self.nodes.len();
+        for (i, c) in self.nodes.iter().enumerate() {
+            if c.label.trim().is_empty() {
+                return Err(CompileError::EmptyCommandLabel(i));
+            }
+        }
+        for e in &self.edges {
+            if e.from >= n || e.to >= n {
+                return Err(CompileError::EdgeOutOfBounds {
+                    from: e.from,
+                    to: e.to,
+                    nodes: n,
+                });
+            }
+        }
+        Ok(())
+    }
+}
+
+// =====================================================================
+// I/O conveniencia (TOML + JSON)
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum LoadError {
+    #[error("io: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("toml: {0}")]
+    Toml(#[from] toml::de::Error),
+    #[error("json: {0}")]
+    Json(#[from] serde_json::Error),
+    #[error("formato desconocido (esperado .toml o .json)")]
+    UnknownFormat,
+}
+
+pub fn load_workspace_spec(path: &std::path::Path) -> Result<WorkspaceSpec, LoadError> {
+    let raw = std::fs::read_to_string(path)?;
+    match path.extension().and_then(|s| s.to_str()) {
+        Some("toml") => Ok(toml::from_str(&raw)?),
+        Some("json") => Ok(serde_json::from_str(&raw)?),
+        _ => Err(LoadError::UnknownFormat),
+    }
+}
+
+pub fn load_pipeline_spec(path: &std::path::Path) -> Result<PipelineSpec, LoadError> {
+    let raw = std::fs::read_to_string(path)?;
+    match path.extension().and_then(|s| s.to_str()) {
+        Some("toml") => Ok(toml::from_str(&raw)?),
+        Some("json") => Ok(serde_json::from_str(&raw)?),
+        _ => Err(LoadError::UnknownFormat),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample_workspace() -> WorkspaceSpec {
+        WorkspaceSpec {
+            label: "demo".into(),
+            soma: SomaSpec::default(),
+            permissions: Permissions::default(),
+            ttl: Some(Duration::from_secs(60)),
+            flow_dirs: vec![FlowSlot {
+                name: "out".into(),
+                direction: FlowDirection::Output,
+                scope: FlowScope::Public,
+            }],
+            on_exit: ExitPolicy::Reap,
+        }
+    }
+
+    #[test]
+    fn workspace_toml_roundtrip() {
+        let ws = sample_workspace();
+        let s = toml::to_string(&ws).unwrap();
+        let back: WorkspaceSpec = toml::from_str(&s).unwrap();
+        assert_eq!(back.label, ws.label);
+        assert_eq!(back.ttl, ws.ttl);
+        assert_eq!(back.flow_dirs.len(), 1);
+    }
+
+    #[test]
+    fn workspace_json_roundtrip() {
+        let ws = sample_workspace();
+        let s = serde_json::to_string(&ws).unwrap();
+        let back: WorkspaceSpec = serde_json::from_str(&s).unwrap();
+        assert_eq!(back.label, ws.label);
+    }
+
+    #[test]
+    fn workspace_compiles_to_card() {
+        let ws = sample_workspace();
+        let id = WorkspaceId::new();
+        let c = ws.to_card(id).unwrap();
+        assert_eq!(c.id, id.0);
+        assert!(c.label.starts_with("shipote.workspace."));
+        assert!(matches!(c.payload, Payload::Virtual));
+    }
+
+    #[test]
+    fn empty_label_rejected() {
+        let mut ws = sample_workspace();
+        ws.label = String::new();
+        assert!(ws.to_card(WorkspaceId::new()).is_err());
+    }
+
+    #[test]
+    fn pipeline_validates_edges() {
+        let p = PipelineSpec {
+            label: "p".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![CommandRef {
+                label: "a".into(),
+                payload: Payload::Virtual,
+                soma: SomaSpec::default(),
+                flows: brahman_card::Flows::default(),
+                supervision: Supervision::OneShot,
+            }],
+            edges: vec![FlowEdge {
+                from: 0,
+                from_output: "x".into(),
+                to: 5,
+                to_input: "y".into(),
+            }],
+            discern: DiscernPolicy::default(),
+        };
+        assert!(p.validate().is_err());
+    }
+
+    #[test]
+    fn intersect_soma_takes_more_restrictive() {
+        let mut child = SomaSpec::default();
+        child.rlimits.mem_bytes = Some(1_000_000);
+        let mut ws = SomaSpec::default();
+        ws.rlimits.mem_bytes = Some(500_000);
+        ws.namespaces.user = true;
+        let r = intersect_soma(&child, &ws);
+        assert_eq!(r.rlimits.mem_bytes, Some(500_000));
+        assert!(r.namespaces.user);
+    }
+}
diff --git a/crates/modules/shipote/shipote-core/Cargo.toml b/crates/modules/shipote/shipote-core/Cargo.toml
new file mode 100644
index 0000000..5567879
--- /dev/null
+++ b/crates/modules/shipote/shipote-core/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "shipote-core"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Runtime de shipote: WorkspaceManager sobre ente-incarnate. Estado in-memory, lifecycle, reaping."
+
+[dependencies]
+shipote-card = { path = "../shipote-card" }
+shipote-discern = { path = "../shipote-discern" }
+brahman-card = { path = "../../../core/brahman-card" }
+ente-incarnate = { path = "../../../shared/ente-incarnate" }
+nix = { workspace = true }
+libc = { workspace = true }
+anyhow = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+ulid = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/modules/shipote/shipote-core/src/lib.rs b/crates/modules/shipote/shipote-core/src/lib.rs
new file mode 100644
index 0000000..080ac64
--- /dev/null
+++ b/crates/modules/shipote/shipote-core/src/lib.rs
@@ -0,0 +1,584 @@
+//! `shipote-core` — runtime in-memory de Workspaces y comandos.
+//!
+//! Mantiene un estado tokio-friendly (Mutex sobre HashMap) con:
+//! - Workspaces vivos (id → state).
+//! - PIDs de comandos lanzados, indexados por workspace.
+//! - Reaping cooperativo: `reap_dead()` cosecha hijos terminados.
+
+// `pipeline` necesita `unsafe` puntual para `libc::close` y construir
+// `OwnedFd` desde fds que armamos con `pipe2(2)`. El resto del crate
+// permanece safe — el cargo lint `unsafe_code` queda permitido sólo en
+// el módulo concreto.
+#![deny(unsafe_op_in_unsafe_fn)]
+
+pub mod logbuf;
+pub mod persist;
+pub mod pipeline;
+
+use brahman_card::{Card, Payload, Supervision};
+use ente_incarnate::{Incarnator, IncarnatorConfig};
+use nix::sys::signal::{kill, Signal};
+use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
+use nix::unistd::Pid;
+use shipote_card::{CommandRef, PipelineSpec, WorkspaceId, WorkspaceSpec};
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::Instant;
+use thiserror::Error;
+use tokio::sync::Mutex;
+use tracing::{info, warn};
+use ulid::Ulid;
+
+#[derive(Debug, Error)]
+pub enum CoreError {
+    #[error("workspace {0} not found")]
+    WorkspaceNotFound(WorkspaceId),
+    #[error("compile: {0}")]
+    Compile(#[from] shipote_card::CompileError),
+    #[error("incarnate: {0}")]
+    Incarnate(#[from] ente_incarnate::IncarnateError),
+}
+
+#[derive(Debug)]
+pub struct WorkspaceState {
+    pub id: WorkspaceId,
+    pub spec: WorkspaceSpec,
+    pub root_card: Card,
+    pub commands: HashMap<Ulid, CommandState>,
+    pub started: Instant,
+}
+
+#[derive(Debug, Clone)]
+pub struct CommandState {
+    pub id: Ulid,
+    pub label: String,
+    pub pid: Pid,
+    pub alive: bool,
+    pub exit_status: Option<i32>,
+    /// Ring buffer compartido con la tokio task que drena stdout+stderr
+    /// del comando. `None` para comandos que no capturan output (futuro:
+    /// comandos con stdout=inherit).
+    pub logs: Option<logbuf::LogBuf>,
+}
+
+pub struct WorkspaceManager {
+    inner: Arc<Mutex<Inner>>,
+    incarnator: Arc<Incarnator>,
+}
+
+struct Inner {
+    workspaces: HashMap<WorkspaceId, WorkspaceState>,
+    /// Definiciones nombradas de pipelines persistidas. NO es lo mismo
+    /// que "pipelines vivos" — son specs guardados para reusar con
+    /// `run-saved`. Sobreviven restart vía snapshot.
+    saved_pipelines: HashMap<String, PipelineSpec>,
+}
+
+#[derive(Debug, Clone)]
+pub struct CommandSummary {
+    pub id: Ulid,
+    pub label: String,
+    pub pid: i32,
+}
+
+#[derive(Debug, Clone)]
+pub struct CommandInfo {
+    pub id: Ulid,
+    pub label: String,
+    pub pid: i32,
+    pub alive: bool,
+    pub exit_status: Option<i32>,
+    pub log_bytes: u64,
+}
+
+fn spawn_log_drainer(read_fd: std::os::fd::RawFd, logs: logbuf::LogBuf) {
+    // Marcar non-blocking + envolver en AsyncFd; igual patrón que el tap.
+    // SAFETY: F_SETFL sobre fd válido.
+    unsafe {
+        let flags = libc::fcntl(read_fd, libc::F_GETFL, 0);
+        if flags >= 0 {
+            libc::fcntl(read_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
+        }
+    }
+    tokio::spawn(async move {
+        // SAFETY: ownership del fd transferido al drainer task.
+        let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(read_fd) };
+        let afd = match tokio::io::unix::AsyncFd::with_interest(owned, tokio::io::Interest::READABLE) {
+            Ok(a) => a,
+            Err(e) => {
+                tracing::warn!(?e, "log drainer AsyncFd failed");
+                return;
+            }
+        };
+        let mut buf = [0u8; 4096];
+        loop {
+            let mut guard = match afd.readable().await {
+                Ok(g) => g,
+                Err(_) => break,
+            };
+            use std::os::fd::AsRawFd;
+            let fd = afd.as_raw_fd();
+            // SAFETY: read sobre fd válido.
+            let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
+            if r > 0 {
+                logs.append(&buf[..r as usize]);
+                continue;
+            }
+            if r == 0 {
+                break; // EOF
+            }
+            let err = std::io::Error::last_os_error();
+            if err.kind() == std::io::ErrorKind::WouldBlock {
+                guard.clear_ready();
+                continue;
+            }
+            tracing::warn!(?err, "log drainer read err");
+            break;
+        }
+    });
+}
+
+trait OwnedFdFromRawCompat: Sized {
+    unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self;
+}
+
+impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
+    unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self {
+        use std::os::fd::FromRawFd;
+        // SAFETY: el caller transfiere ownership de fd a OwnedFd.
+        unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
+    }
+}
+
+impl WorkspaceManager {
+    pub fn new(cfg: IncarnatorConfig) -> Self {
+        Self {
+            inner: Arc::new(Mutex::new(Inner {
+                workspaces: HashMap::new(),
+                saved_pipelines: HashMap::new(),
+            })),
+            incarnator: Arc::new(Incarnator::new(cfg)),
+        }
+    }
+
+    pub fn incarnator(&self) -> &Incarnator {
+        &self.incarnator
+    }
+
+    /// Handle Arc-clonable del Incarnator, para que el pipeline lo pueda
+    /// usar fuera del manager.
+    pub fn incarnator_handle(&self) -> Arc<Incarnator> {
+        self.incarnator.clone()
+    }
+
+    // -----------------------------------------------------------------
+    // Saved pipelines (definiciones nombradas, no runs)
+    // -----------------------------------------------------------------
+
+    /// Guarda (o reemplaza) un PipelineSpec bajo `name`.
+    pub async fn save_pipeline(&self, name: String, spec: PipelineSpec) {
+        self.inner.lock().await.saved_pipelines.insert(name, spec);
+    }
+
+    /// Devuelve los nombres de los pipelines guardados.
+    pub async fn list_saved_pipelines(&self) -> Vec<String> {
+        let g = self.inner.lock().await;
+        let mut v: Vec<String> = g.saved_pipelines.keys().cloned().collect();
+        v.sort();
+        v
+    }
+
+    /// Recupera el PipelineSpec guardado bajo `name`.
+    pub async fn get_saved_pipeline(&self, name: &str) -> Option<PipelineSpec> {
+        self.inner.lock().await.saved_pipelines.get(name).cloned()
+    }
+
+    /// Elimina un saved pipeline.
+    pub async fn drop_saved_pipeline(&self, name: &str) -> bool {
+        self.inner.lock().await.saved_pipelines.remove(name).is_some()
+    }
+
+    /// Label del workspace, si existe.
+    pub async fn workspace_label(&self, id: WorkspaceId) -> Option<String> {
+        self.inner
+            .lock()
+            .await
+            .workspaces
+            .get(&id)
+            .map(|w| w.spec.label.clone())
+    }
+
+    pub async fn create(
+        self: &Arc<Self>,
+        spec: WorkspaceSpec,
+    ) -> Result<(WorkspaceId, Vec<String>), CoreError> {
+        self.create_with_id(WorkspaceId::new(), spec).await
+    }
+
+    /// Variante que acepta el ID. Útil para restore_snapshot: preserva
+    /// ULIDs entre restarts, así clients que tracking workspace_id no se
+    /// rompen.
+    pub async fn create_with_id(
+        self: &Arc<Self>,
+        id: WorkspaceId,
+        spec: WorkspaceSpec,
+    ) -> Result<(WorkspaceId, Vec<String>), CoreError> {
+        let card = spec.to_card(id)?;
+        let warnings = self.incarnator.dry_run(&card).warnings;
+        let ttl = spec.ttl;
+        let state = WorkspaceState {
+            id,
+            spec,
+            root_card: card,
+            commands: HashMap::new(),
+            started: Instant::now(),
+        };
+        self.inner.lock().await.workspaces.insert(id, state);
+        info!(%id, ?ttl, "workspace created");
+
+        // Si tiene TTL, programar auto-stop. El task captura un weak ref
+        // al manager para no impedir que se dropée si el daemon termina.
+        if let Some(duration) = ttl {
+            let mgr_weak = Arc::downgrade(self);
+            tokio::spawn(async move {
+                tokio::time::sleep(duration).await;
+                if let Some(mgr) = mgr_weak.upgrade() {
+                    let exists = mgr.inner.lock().await.workspaces.contains_key(&id);
+                    if exists {
+                        info!(%id, "workspace TTL expired — auto-stop");
+                        let _ = mgr.stop(id).await;
+                    }
+                }
+            });
+        }
+
+        Ok((id, warnings))
+    }
+
+    pub async fn list(&self) -> Vec<WorkspaceSnapshot> {
+        let g = self.inner.lock().await;
+        g.workspaces
+            .values()
+            .map(|w| WorkspaceSnapshot {
+                id: w.id,
+                label: w.spec.label.clone(),
+                commands: w.commands.len() as u32,
+                uptime_ms: w.started.elapsed().as_millis() as u64,
+            })
+            .collect()
+    }
+
+    pub async fn stop(&self, id: WorkspaceId) -> Result<u32, CoreError> {
+        let mut g = self.inner.lock().await;
+        let ws = g.workspaces.remove(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
+        let mut reaped = 0u32;
+        for (_cid, cmd) in ws.commands {
+            if cmd.alive {
+                let _ = kill(cmd.pid, Signal::SIGTERM);
+                // Cosecha sin bloquear infinito: WNOHANG en loop con un par de intentos.
+                for _ in 0..50 {
+                    match waitpid(cmd.pid, Some(WaitPidFlag::WNOHANG)) {
+                        Ok(WaitStatus::StillAlive) => {
+                            std::thread::sleep(std::time::Duration::from_millis(20));
+                        }
+                        Ok(_) => {
+                            reaped += 1;
+                            break;
+                        }
+                        Err(_) => break,
+                    }
+                }
+                // Último recurso: SIGKILL.
+                let _ = kill(cmd.pid, Signal::SIGKILL);
+                let _ = waitpid(cmd.pid, None);
+            }
+        }
+        info!(%id, reaped, "workspace stopped");
+        Ok(reaped)
+    }
+
+    /// Ejecuta un comando one-shot dentro de un workspace existente.
+    /// Captura stdout+stderr en un ring buffer accesible vía
+    /// [`get_command_logs`](Self::get_command_logs).
+    pub async fn run(
+        &self,
+        id: WorkspaceId,
+        exec: String,
+        argv: Vec<String>,
+        envp: Vec<(String, String)>,
+    ) -> Result<CommandSummary, CoreError> {
+        let workspace_label = {
+            let g = self.inner.lock().await;
+            let ws = g.workspaces.get(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
+            ws.spec.label.clone()
+        };
+        let cmd_ref = CommandRef {
+            label: format!("run-{}", short_ulid(&Ulid::new())),
+            payload: Payload::Native { exec, argv, envp },
+            soma: Default::default(),
+            flows: Default::default(),
+            supervision: Supervision::OneShot,
+        };
+        let card = cmd_ref.to_card(0, &workspace_label)?;
+
+        // Pipe para capturar stdout. O_CLOEXEC para que hijos del hijo
+        // no hereden la copia. v1: stderr=inherit (simplicidad; tail útil
+        // para stdout solo). Futuro: stderr separado en el ring.
+        let (capture_r, capture_w) =
+            nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
+                CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
+            })?;
+        use std::os::fd::IntoRawFd;
+        let capture_r_fd = capture_r.into_raw_fd();
+        let capture_w_fd = capture_w.into_raw_fd();
+
+        let logs = logbuf::LogBuf::new();
+
+        let stdio = ente_incarnate::ChildStdio {
+            stdin_fd: None,
+            stdout_fd: Some(capture_w_fd),
+            stderr_fd: None,
+        };
+        let out = self.incarnator.incarnate_with(&card, stdio)?;
+        let cmd_id = card.id;
+        let cmd_label = cmd_ref.label.clone();
+        let pid = out.pid;
+
+        // Drainer: tokio task que lee capture_r_fd y appendea al ring.
+        spawn_log_drainer(capture_r_fd, logs.clone());
+
+        let mut g = self.inner.lock().await;
+        if let Some(ws) = g.workspaces.get_mut(&id) {
+            ws.commands.insert(
+                cmd_id,
+                CommandState {
+                    id: cmd_id,
+                    label: cmd_label.clone(),
+                    pid,
+                    alive: true,
+                    exit_status: None,
+                    logs: Some(logs),
+                },
+            );
+        }
+        for d in &out.degradations {
+            warn!(?d, %id, "command incarnation degradation");
+        }
+        Ok(CommandSummary {
+            id: cmd_id,
+            label: cmd_label,
+            pid: pid.as_raw(),
+        })
+    }
+
+    /// Devuelve el tail del log capturado para `(workspace, command)`.
+    pub async fn get_command_logs(
+        &self,
+        workspace: WorkspaceId,
+        command: Ulid,
+        tail_bytes: usize,
+    ) -> Option<Vec<u8>> {
+        let g = self.inner.lock().await;
+        let ws = g.workspaces.get(&workspace)?;
+        let cmd = ws.commands.get(&command)?;
+        cmd.logs.as_ref().map(|lb| lb.tail(tail_bytes))
+    }
+
+    /// Lista comandos de un workspace.
+    pub async fn list_commands(&self, workspace: WorkspaceId) -> Vec<CommandInfo> {
+        let g = self.inner.lock().await;
+        let Some(ws) = g.workspaces.get(&workspace) else { return Vec::new() };
+        let mut out: Vec<CommandInfo> = ws
+            .commands
+            .values()
+            .map(|c| CommandInfo {
+                id: c.id,
+                label: c.label.clone(),
+                pid: c.pid.as_raw(),
+                alive: c.alive,
+                exit_status: c.exit_status,
+                log_bytes: c.logs.as_ref().map(|l| l.written_total()).unwrap_or(0),
+            })
+            .collect();
+        // Orden estable por ULID (temporal).
+        out.sort_by_key(|c| c.id);
+        out
+    }
+
+    /// Lanza todas las Cards de un Pipeline. Devuelve (label, pid) por nodo.
+    /// La conexión via flows queda librada al broker (cuando haya integración
+    /// completa con sidecar; v1 sólo lanza).
+    pub async fn run_pipeline(
+        &self,
+        spec: &PipelineSpec,
+    ) -> Result<Vec<(String, Pid)>, CoreError> {
+        spec.validate()?;
+        let workspace_label = {
+            let g = self.inner.lock().await;
+            let ws = g
+                .workspaces
+                .get(&spec.workspace)
+                .ok_or(CoreError::WorkspaceNotFound(spec.workspace))?;
+            ws.spec.label.clone()
+        };
+        let mut launched = Vec::new();
+        for (i, node) in spec.nodes.iter().enumerate() {
+            let card = node.to_card(i, &workspace_label)?;
+            let out = self.incarnator.incarnate(&card)?;
+            let mut g = self.inner.lock().await;
+            if let Some(ws) = g.workspaces.get_mut(&spec.workspace) {
+                ws.commands.insert(
+                    card.id,
+                    CommandState {
+                        id: card.id,
+                        label: node.label.clone(),
+                        pid: out.pid,
+                        alive: true,
+                        exit_status: None,
+                        logs: None, // run_pipeline NO captura logs (los conecta por pipes).
+                    },
+                );
+            }
+            launched.push((node.label.clone(), out.pid));
+        }
+        Ok(launched)
+    }
+
+    /// Cosecha hijos terminados (no-bloqueante). Llamar periódicamente desde
+    /// el daemon o ante SIGCHLD. Marca `alive=false` y guarda exit_status.
+    pub async fn reap_dead(&self) {
+        let mut g = self.inner.lock().await;
+        for ws in g.workspaces.values_mut() {
+            for cmd in ws.commands.values_mut() {
+                if !cmd.alive {
+                    continue;
+                }
+                match waitpid(cmd.pid, Some(WaitPidFlag::WNOHANG)) {
+                    Ok(WaitStatus::Exited(_, code)) => {
+                        cmd.alive = false;
+                        cmd.exit_status = Some(code);
+                    }
+                    Ok(WaitStatus::Signaled(_, sig, _)) => {
+                        cmd.alive = false;
+                        cmd.exit_status = Some(128 + (sig as i32));
+                    }
+                    _ => {}
+                }
+            }
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceSnapshot {
+    pub id: WorkspaceId,
+    pub label: String,
+    pub commands: u32,
+    pub uptime_ms: u64,
+}
+
+fn short_ulid(u: &Ulid) -> String {
+    let s = u.to_string();
+    s[s.len() - 6..].to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn ttl_auto_stops_workspace() {
+        let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let spec = WorkspaceSpec {
+            label: "ttl-test".into(),
+            soma: Default::default(),
+            permissions: Default::default(),
+            ttl: Some(std::time::Duration::from_millis(120)),
+            flow_dirs: vec![],
+            on_exit: shipote_card::ExitPolicy::Reap,
+        };
+        let (id, _) = mgr.create(spec).await.unwrap();
+        assert_eq!(mgr.list().await.len(), 1);
+        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+        assert_eq!(
+            mgr.list().await.len(),
+            0,
+            "TTL expirado: workspace debe haber sido removido"
+        );
+        let _ = id;
+    }
+
+    #[tokio::test]
+    async fn create_and_list_workspace() {
+        let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let spec = WorkspaceSpec {
+            label: "test".into(),
+            soma: Default::default(),
+            permissions: Default::default(),
+            ttl: None,
+            flow_dirs: vec![],
+            on_exit: shipote_card::ExitPolicy::Reap,
+        };
+        let (id, _w) = mgr.create(spec).await.unwrap();
+        let list = mgr.list().await;
+        assert_eq!(list.len(), 1);
+        assert_eq!(list[0].id, id);
+    }
+
+    #[tokio::test]
+    async fn run_captures_stdout_to_log() {
+        let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let spec = WorkspaceSpec {
+            label: "logs".into(),
+            soma: Default::default(),
+            permissions: Default::default(),
+            ttl: None,
+            flow_dirs: vec![],
+            on_exit: shipote_card::ExitPolicy::Reap,
+        };
+        let (id, _) = mgr.create(spec).await.unwrap();
+        let summary = mgr
+            .run(
+                id,
+                "/bin/echo".into(),
+                vec!["captured-output".into()],
+                vec![],
+            )
+            .await
+            .unwrap();
+        // Esperamos a que el comando termine y el drainer drene.
+        for _ in 0..50 {
+            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+            mgr.reap_dead().await;
+            let logs = mgr.get_command_logs(id, summary.id, 0).await.unwrap_or_default();
+            if !logs.is_empty() {
+                let s = String::from_utf8_lossy(&logs);
+                assert!(s.contains("captured-output"), "got: {s:?}");
+                return;
+            }
+        }
+        panic!("logs never captured");
+    }
+
+    #[tokio::test]
+    async fn run_true_in_workspace() {
+        let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let spec = WorkspaceSpec {
+            label: "exec".into(),
+            soma: Default::default(),
+            permissions: Default::default(),
+            ttl: None,
+            flow_dirs: vec![],
+            on_exit: shipote_card::ExitPolicy::Reap,
+        };
+        let (id, _) = mgr.create(spec).await.unwrap();
+        let summary = mgr
+            .run(id, "/bin/true".into(), vec![], vec![])
+            .await
+            .unwrap();
+        assert!(summary.pid > 0);
+        // Cosecha.
+        std::thread::sleep(std::time::Duration::from_millis(100));
+        mgr.reap_dead().await;
+    }
+}
diff --git a/crates/modules/shipote/shipote-core/src/logbuf.rs b/crates/modules/shipote/shipote-core/src/logbuf.rs
new file mode 100644
index 0000000..a5452c0
--- /dev/null
+++ b/crates/modules/shipote/shipote-core/src/logbuf.rs
@@ -0,0 +1,122 @@
+//! Ring buffer en memoria para capturar stdout/stderr de comandos.
+//!
+//! Tamaño fijo por comando (config: `MAX_LOG_BYTES`). Cuando se llena,
+//! descarta los bytes más viejos. Pensado para diagnostico rápido, no
+//! para retención histórica — eso es trabajo de un journald-like aparte.
+
+use std::sync::{Arc, Mutex};
+
+/// Bytes máximos retenidos por comando. 64 KiB cubre logs típicos sin
+/// abusar de memoria si el daemon tiene cientos de comandos vivos.
+pub const MAX_LOG_BYTES: usize = 64 * 1024;
+
+#[derive(Debug, Clone)]
+pub struct LogBuf {
+    inner: Arc<Mutex<Inner>>,
+}
+
+#[derive(Debug)]
+struct Inner {
+    /// Bytes raw. Cuando se acerca al cap, descartamos head para mantener
+    /// el tail.
+    buf: Vec<u8>,
+    cap: usize,
+    /// Total escrito alguna vez (no decrementado al recortar).
+    written_total: u64,
+}
+
+impl LogBuf {
+    pub fn new() -> Self {
+        Self::with_cap(MAX_LOG_BYTES)
+    }
+
+    pub fn with_cap(cap: usize) -> Self {
+        Self {
+            inner: Arc::new(Mutex::new(Inner {
+                buf: Vec::with_capacity(cap.min(4096)),
+                cap,
+                written_total: 0,
+            })),
+        }
+    }
+
+    pub fn append(&self, data: &[u8]) {
+        let Ok(mut g) = self.inner.lock() else { return };
+        g.written_total += data.len() as u64;
+        g.buf.extend_from_slice(data);
+        // Recorte cuando excede cap (con un pequeño slack para evitar
+        // shift en cada append). El usuario ve sólo el tail.
+        if g.buf.len() > g.cap + 1024 {
+            let drop = g.buf.len() - g.cap;
+            g.buf.drain(..drop);
+        }
+    }
+
+    /// Devuelve el tail de hasta `n` bytes (o todo si `n=0`).
+    pub fn tail(&self, n: usize) -> Vec<u8> {
+        let g = match self.inner.lock() {
+            Ok(g) => g,
+            Err(_) => return Vec::new(),
+        };
+        if n == 0 || n >= g.buf.len() {
+            return g.buf.clone();
+        }
+        g.buf[g.buf.len() - n..].to_vec()
+    }
+
+    /// Cuántos bytes hay actualmente en el buffer.
+    pub fn len(&self) -> usize {
+        self.inner.lock().map(|g| g.buf.len()).unwrap_or(0)
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    pub fn written_total(&self) -> u64 {
+        self.inner.lock().map(|g| g.written_total).unwrap_or(0)
+    }
+}
+
+impl Default for LogBuf {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn append_and_tail_basic() {
+        let lb = LogBuf::with_cap(100);
+        lb.append(b"hello ");
+        lb.append(b"world\n");
+        let t = lb.tail(0);
+        assert_eq!(t, b"hello world\n");
+    }
+
+    #[test]
+    fn cap_drops_oldest() {
+        let lb = LogBuf::with_cap(10);
+        lb.append(&[b'a'; 8]);
+        lb.append(&[b'b'; 8]);
+        // Después del recorte, debe quedar ~10 bytes pero el slack
+        // permite hasta 10+1024. Como pasamos slack, no se recorta aún
+        // en este caso (16 bytes < 10+1024). Forzamos un append grande.
+        lb.append(&[b'c'; 2048]);
+        assert!(lb.len() <= 10 + 1024);
+        let t = lb.tail(0);
+        // El tail debe contener 'c's (los más recientes).
+        assert!(t.iter().filter(|&&b| b == b'c').count() > 0);
+    }
+
+    #[test]
+    fn written_total_tracks_all() {
+        let lb = LogBuf::with_cap(10);
+        lb.append(b"abcdef");
+        lb.append(b"ghijkl");
+        assert_eq!(lb.written_total(), 12);
+    }
+}
diff --git a/crates/modules/shipote/shipote-core/src/persist.rs b/crates/modules/shipote/shipote-core/src/persist.rs
new file mode 100644
index 0000000..a4e1e44
--- /dev/null
+++ b/crates/modules/shipote/shipote-core/src/persist.rs
@@ -0,0 +1,228 @@
+//! Persistencia del estado del WorkspaceManager.
+//!
+//! v1: sólo `WorkspaceSpec`s vivos. Los comandos (PIDs) NO se persisten —
+//! el kernel los mata al cerrar el daemon. Sólo la *intención declarada*
+//! (Workspaces creados con su spec) sobrevive a un reboot del daemon.
+
+use crate::WorkspaceManager;
+use serde::{Deserialize, Serialize};
+use shipote_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
+use std::path::{Path, PathBuf};
+use tracing::{info, warn};
+
+/// v2 agregó `saved_pipelines`. v1 lee con campo ausente como vacío.
+pub const SNAPSHOT_VERSION: u16 = 2;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ShipoteSnapshot {
+    pub version: u16,
+    pub timestamp_ms: u64,
+    pub workspaces: Vec<WorkspaceEntry>,
+    #[serde(default)]
+    pub saved_pipelines: Vec<PipelineEntry>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WorkspaceEntry {
+    pub id: WorkspaceId,
+    pub spec: WorkspaceSpec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PipelineEntry {
+    pub name: String,
+    pub spec: PipelineSpec,
+}
+
+impl ShipoteSnapshot {
+    pub fn write(&self, path: &Path) -> anyhow::Result<()> {
+        let bytes = serde_json::to_vec_pretty(self)?;
+        if let Some(parent) = path.parent() {
+            std::fs::create_dir_all(parent).ok();
+        }
+        let tmp = path.with_extension("tmp");
+        std::fs::write(&tmp, &bytes)?;
+        std::fs::rename(&tmp, path)?;
+        Ok(())
+    }
+
+    pub fn read(path: &Path) -> anyhow::Result<Self> {
+        let bytes = std::fs::read(path)?;
+        let snap: ShipoteSnapshot = serde_json::from_slice(&bytes)?;
+        // v1 y v2 son compatibles forward (v1 sin saved_pipelines lee como vec vacío).
+        if snap.version > SNAPSHOT_VERSION {
+            anyhow::bail!(
+                "snapshot version {} no soportada (esperada ≤ {})",
+                snap.version,
+                SNAPSHOT_VERSION
+            );
+        }
+        Ok(snap)
+    }
+}
+
+/// Path canónico del snapshot: `$XDG_STATE_HOME/shipote/state.json`,
+/// fallback `$HOME/.local/state/shipote/state.json`,
+/// fallback `/tmp/shipote-state-$UID.json`.
+pub fn default_snapshot_path() -> PathBuf {
+    if let Ok(state) = std::env::var("XDG_STATE_HOME") {
+        return PathBuf::from(state).join("shipote/state.json");
+    }
+    if let Ok(home) = std::env::var("HOME") {
+        return PathBuf::from(home).join(".local/state/shipote/state.json");
+    }
+    let uid = nix::unistd::getuid().as_raw();
+    PathBuf::from(format!("/tmp/shipote-state-{uid}.json"))
+}
+
+fn now_ms() -> u64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_millis() as u64)
+        .unwrap_or(0)
+}
+
+impl WorkspaceManager {
+    /// Toma snapshot del estado actual.
+    pub async fn snapshot(&self) -> ShipoteSnapshot {
+        let g = self.inner.lock().await;
+        let workspaces = g
+            .workspaces
+            .iter()
+            .map(|(id, ws)| WorkspaceEntry {
+                id: *id,
+                spec: ws.spec.clone(),
+            })
+            .collect();
+        let saved_pipelines = g
+            .saved_pipelines
+            .iter()
+            .map(|(name, spec)| PipelineEntry {
+                name: name.clone(),
+                spec: spec.clone(),
+            })
+            .collect();
+        ShipoteSnapshot {
+            version: SNAPSHOT_VERSION,
+            timestamp_ms: now_ms(),
+            workspaces,
+            saved_pipelines,
+        }
+    }
+
+    /// Escribe snapshot a disco.
+    pub async fn save_snapshot(&self, path: &Path) -> anyhow::Result<()> {
+        let snap = self.snapshot().await;
+        snap.write(path)?;
+        info!(path = %path.display(), workspaces = snap.workspaces.len(), "snapshot saved");
+        Ok(())
+    }
+
+    /// Carga snapshot desde disco y restaura los Workspaces.
+    /// Errores no-fatales (workspaces inválidos) se loguean y se saltan.
+    pub async fn restore_snapshot(self: &std::sync::Arc<Self>, path: &Path) -> anyhow::Result<usize> {
+        let snap = match ShipoteSnapshot::read(path) {
+            Ok(s) => s,
+            Err(e) => {
+                warn!(?e, path = %path.display(), "no snapshot — start fresh");
+                return Ok(0);
+            }
+        };
+        let mut restored = 0usize;
+        for entry in snap.workspaces {
+            // v2+: reusamos el id original así clients que tracking
+            // workspace_id no se rompen al restart.
+            let label = entry.spec.label.clone();
+            match self.create_with_id(entry.id, entry.spec).await {
+                Ok(_) => restored += 1,
+                Err(e) => warn!(?e, %label, "skipped workspace en restore"),
+            }
+        }
+        for entry in snap.saved_pipelines {
+            self.save_pipeline(entry.name, entry.spec).await;
+        }
+        info!(restored, "snapshot restored");
+        Ok(restored)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::WorkspaceManager;
+    use ente_incarnate::IncarnatorConfig;
+    use shipote_card::{ExitPolicy, WorkspaceSpec};
+    use std::sync::Arc;
+
+    fn sample_ws(label: &str) -> WorkspaceSpec {
+        WorkspaceSpec {
+            label: label.into(),
+            soma: Default::default(),
+            permissions: Default::default(),
+            ttl: None,
+            flow_dirs: vec![],
+            on_exit: ExitPolicy::Reap,
+        }
+    }
+
+    #[tokio::test]
+    async fn roundtrip_snapshot_preserves_ulids() {
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("state.json");
+
+        let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let (id1, _) = mgr1.create(sample_ws("a")).await.unwrap();
+        let (id2, _) = mgr1.create(sample_ws("b")).await.unwrap();
+        mgr1.save_snapshot(&path).await.unwrap();
+
+        let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let n = mgr2.restore_snapshot(&path).await.unwrap();
+        assert_eq!(n, 2);
+        let listed = mgr2.list().await;
+        let restored_ids: std::collections::HashSet<_> = listed.iter().map(|s| s.id).collect();
+        assert!(restored_ids.contains(&id1));
+        assert!(restored_ids.contains(&id2));
+    }
+
+    #[tokio::test]
+    async fn snapshot_includes_saved_pipelines() {
+        use shipote_card::{CommandRef, DiscernPolicy, PipelineSpec};
+        let tmp = tempfile::tempdir().unwrap();
+        let path = tmp.path().join("state.json");
+
+        let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        let (ws_id, _) = mgr1.create(sample_ws("ws")).await.unwrap();
+        let spec = PipelineSpec {
+            label: "echo-cat".into(),
+            workspace: ws_id,
+            nodes: vec![CommandRef {
+                label: "n1".into(),
+                payload: brahman_card::Payload::Native {
+                    exec: "/bin/echo".into(),
+                    argv: vec!["hi".into()],
+                    envp: vec![],
+                },
+                soma: Default::default(),
+                flows: Default::default(),
+                supervision: brahman_card::Supervision::OneShot,
+            }],
+            edges: vec![],
+            discern: DiscernPolicy::default(),
+        };
+        mgr1.save_pipeline("daily".into(), spec).await;
+        mgr1.save_snapshot(&path).await.unwrap();
+
+        let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+        mgr2.restore_snapshot(&path).await.unwrap();
+        let saved = mgr2.list_saved_pipelines().await;
+        assert_eq!(saved, vec!["daily".to_string()]);
+        let got = mgr2.get_saved_pipeline("daily").await.expect("saved");
+        assert_eq!(got.label, "echo-cat");
+    }
+
+    #[test]
+    fn default_path_ends_with_state_json() {
+        let p = default_snapshot_path();
+        assert!(p.to_string_lossy().ends_with("state.json"));
+    }
+}
diff --git a/crates/modules/shipote/shipote-core/src/pipeline.rs b/crates/modules/shipote/shipote-core/src/pipeline.rs
new file mode 100644
index 0000000..9554cd1
--- /dev/null
+++ b/crates/modules/shipote/shipote-core/src/pipeline.rs
@@ -0,0 +1,420 @@
+//! Pipeline runtime: encadena nodos con pipes y opcionalmente intercepta
+//! cada flow para discernir su contenido.
+//!
+//! Cada nodo se encarna via [`ente_incarnate::Incarnator`] — eso significa
+//! que **cada comando puede tener su propio SomaSpec** (namespaces, cgroup,
+//! rlimits) heredado del workspace. La conexión stdin↔stdout se hace con
+//! `pipe2(2)` + `ChildStdio` declarativo: el callback de clone(2) hace los
+//! `dup2` pre-execve sin romper la regla async-signal-safe.
+
+use crate::CoreError;
+use brahman_card::Payload;
+use ente_incarnate::{ChildStdio, Incarnator};
+use nix::fcntl::OFlag;
+use nix::unistd::pipe2;
+use shipote_card::{FlowEdge, PipelineSpec};
+use shipote_discern::{DiscernPipeline, Discernment, Hint};
+use std::os::fd::{AsRawFd, IntoRawFd, RawFd};
+use std::sync::Arc;
+use tokio::io::unix::AsyncFd;
+use tokio::io::Interest;
+use tracing::{debug, info, warn};
+use ulid::Ulid;
+
+/// Resultado de lanzar un pipeline.
+#[derive(Debug, Clone)]
+pub struct PipelineLaunch {
+    pub pipeline: Ulid,
+    pub command_pids: Vec<(String, i32)>,
+    /// Discernments por edge, en el mismo orden que `spec.edges`.
+    pub edge_discernments: Vec<EdgeDiscernment>,
+}
+
+#[derive(Debug, Clone)]
+pub struct EdgeDiscernment {
+    pub from_label: String,
+    pub from_output: String,
+    pub to_label: String,
+    pub to_input: String,
+    pub discernment: Option<Discernment>,
+}
+
+/// Lanza un pipeline conectando nodos por stdin/stdout. Cada nodo se
+/// encarna via `Incarnator` (con o sin namespacing según su SomaSpec).
+///
+/// v1: pipeline lineal (un edge entrante por nodo). Múltiples edges
+/// entrantes generan warning y sólo el primero se honra.
+pub async fn run_pipeline(
+    spec: &PipelineSpec,
+    workspace_label: &str,
+    tap: bool,
+    discerner: Arc<DiscernPipeline>,
+    incarnator: Arc<Incarnator>,
+) -> Result<PipelineLaunch, CoreError> {
+    spec.validate()?;
+    let n = spec.nodes.len();
+    info!(
+        nodes = n,
+        edges = spec.edges.len(),
+        tap,
+        "launching pipeline (incarnated)"
+    );
+
+    // Predecessor: para cada nodo, su edge entrante (si tiene).
+    let mut predecessor: Vec<Option<&FlowEdge>> = vec![None; n];
+    for e in &spec.edges {
+        if predecessor[e.to].is_some() {
+            warn!(node = e.to, "v1 pipeline: nodo con múltiples predecessors — sólo se honra el primero");
+            continue;
+        }
+        predecessor[e.to] = Some(e);
+    }
+
+    let mut pids = Vec::with_capacity(n);
+    let mut taps: Vec<TapHandle> = Vec::new();
+    // Para cada nodo i que produce, guardamos el FD de read del pipe
+    // del productor → al armar el consumidor lo consume.
+    // Pero como puede haber tap intermedio, llevamos un esquema:
+    //   - Sin tap: read FD del pipe productor → stdin del consumidor.
+    //   - Con tap: read FD del pipe productor → tokio proxy → write FD
+    //              del pipe consumidor → stdin del consumidor.
+    // Para simplicidad lineal, `pending_stdin_for_next` guarda el FD que
+    // el siguiente consumidor debe usar como stdin.
+    let mut pending_stdin_for_next: Option<RawFd> = None;
+
+    for (i, node) in spec.nodes.iter().enumerate() {
+        // Validar payload ejecutable.
+        match &node.payload {
+            Payload::Native { .. } | Payload::Legacy { .. } => {}
+            _ => {
+                return Err(CoreError::Incarnate(
+                    ente_incarnate::IncarnateError::NonExecutablePayload,
+                ))
+            }
+        }
+
+        // Compilamos a Card.
+        let card = node.to_card(i, workspace_label)?;
+
+        // ¿Soy productor? Necesito stdout_fd hacia un pipe nuevo.
+        let i_is_producer = spec.edges.iter().any(|e| e.from == i);
+        let stdin_fd: Option<RawFd> = pending_stdin_for_next.take();
+        let mut stdout_fd: Option<RawFd> = None;
+        let mut next_pending: Option<RawFd> = None;
+
+        // FDs que el PADRE debe cerrar tras spawn (son nuestra copia del
+        // extremo que pasamos al hijo).
+        let mut parent_closes: Vec<RawFd> = Vec::new();
+
+        if i_is_producer {
+            let (r, w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
+                CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
+            })?;
+            let r_raw = r.into_raw_fd();
+            let w_raw = w.into_raw_fd();
+            stdout_fd = Some(w_raw);
+            parent_closes.push(w_raw);
+
+            if tap {
+                // Necesitamos un segundo pipe entre tap y consumidor.
+                let (r2, w2) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
+                    CoreError::Incarnate(ente_incarnate::IncarnateError::Pipe(e))
+                })?;
+                let r2_raw = r2.into_raw_fd();
+                let w2_raw = w2.into_raw_fd();
+                next_pending = Some(r2_raw);
+                // El tap lee de r_raw y escribe a w2_raw.
+                let edge = predecessor
+                    .iter()
+                    .find_map(|p| *p)
+                    .and_then(|e| if e.from == i { Some(e) } else { None })
+                    // Edge donde i es from:
+                    .or_else(|| spec.edges.iter().find(|e| e.from == i));
+                let from_label = node.label.clone();
+                let to_label = edge
+                    .map(|e| spec.nodes[e.to].label.clone())
+                    .unwrap_or_default();
+                let from_output = edge.map(|e| e.from_output.clone()).unwrap_or_default();
+                let to_input = edge.map(|e| e.to_input.clone()).unwrap_or_default();
+                let sample_bytes = spec.discern.sample_bytes;
+                let disc = discerner.clone();
+                let h = spawn_tap(
+                    r_raw, w2_raw, sample_bytes, disc, from_label, from_output, to_label, to_input,
+                );
+                taps.push(h);
+                // r_raw y w2_raw pasaron a manos del tokio task. No los
+                // cerramos en el padre.
+            } else {
+                // Sin tap, el read del productor va directo al stdin del
+                // siguiente consumidor.
+                next_pending = Some(r_raw);
+            }
+        }
+
+        let stdio = ChildStdio {
+            stdin_fd,
+            stdout_fd,
+            stderr_fd: None,
+        };
+
+        // Incarnator absorbe los fds de `stdio` — no los cerramos acá.
+        // `parent_closes` queda obsoleto.
+        let _ = parent_closes;
+        let outcome = incarnator
+            .incarnate_with(&card, stdio)
+            .map_err(CoreError::Incarnate)?;
+        let pid = outcome.pid;
+        pids.push((node.label.clone(), pid.as_raw()));
+        debug!(label = %node.label, pid = pid.as_raw(), "node incarnated");
+
+        pending_stdin_for_next = next_pending;
+    }
+
+    let pipeline_id = Ulid::new();
+
+    let mut edge_discernments = Vec::with_capacity(taps.len());
+    for t in taps {
+        match t.handle.await {
+            Ok(d) => edge_discernments.push(d),
+            Err(e) => warn!(?e, "tap handle joined with error"),
+        }
+    }
+
+    Ok(PipelineLaunch {
+        pipeline: pipeline_id,
+        command_pids: pids,
+        edge_discernments,
+    })
+}
+
+struct TapHandle {
+    handle: tokio::task::JoinHandle<EdgeDiscernment>,
+}
+
+#[allow(clippy::too_many_arguments)]
+fn spawn_tap(
+    producer_r_fd: RawFd,
+    consumer_w_fd: RawFd,
+    sample_bytes: usize,
+    discerner: Arc<DiscernPipeline>,
+    from_label: String,
+    from_output: String,
+    to_label: String,
+    to_input: String,
+) -> TapHandle {
+    // Marcar non-blocking ANTES de envolverlos en AsyncFd. Sino tokio
+    // bloquea el reactor en operaciones lentas.
+    set_nonblocking(producer_r_fd);
+    set_nonblocking(consumer_w_fd);
+
+    let handle = tokio::spawn(async move {
+        // SAFETY: el caller transfiere ownership de los fds al task.
+        let r_std = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(producer_r_fd) };
+        let w_std = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(consumer_w_fd) };
+        let r = AsyncFd::with_interest(r_std, Interest::READABLE).expect("AsyncFd r");
+        let w = AsyncFd::with_interest(w_std, Interest::WRITABLE).expect("AsyncFd w");
+
+        let mut sample: Vec<u8> = Vec::with_capacity(sample_bytes);
+        let mut buf = [0u8; 4096];
+        let mut total: u64 = 0;
+
+        // Fase 1: sampling + pump.
+        let mut eof = false;
+        while !eof && sample.len() < sample_bytes {
+            let n = match async_read(&r, &mut buf).await {
+                Ok(0) => { eof = true; 0 }
+                Ok(n) => n,
+                Err(e) => { warn!(?e, "tap producer read failed"); break; }
+            };
+            if n == 0 { break; }
+            let take = n.min(sample_bytes - sample.len());
+            sample.extend_from_slice(&buf[..take]);
+            if let Err(e) = async_write_all(&w, &buf[..n]).await {
+                warn!(?e, "tap consumer write failed");
+                break;
+            }
+            total += n as u64;
+        }
+        let d = discerner.discern(&sample, &Hint { path: None, size_total: None });
+
+        // Fase 2: pump-only hasta EOF.
+        while !eof {
+            let n = match async_read(&r, &mut buf).await {
+                Ok(0) => { eof = true; 0 }
+                Ok(n) => n,
+                Err(_) => break,
+            };
+            if n == 0 { break; }
+            if async_write_all(&w, &buf[..n]).await.is_err() { break; }
+            total += n as u64;
+        }
+        debug!(bytes = total, "tap finished");
+        EdgeDiscernment {
+            from_label,
+            from_output,
+            to_label,
+            to_input,
+            discernment: d,
+        }
+    });
+    TapHandle { handle }
+}
+
+async fn async_read(
+    afd: &AsyncFd<std::os::fd::OwnedFd>,
+    buf: &mut [u8],
+) -> std::io::Result<usize> {
+    loop {
+        let mut guard = afd.readable().await?;
+        let fd = afd.as_raw_fd();
+        // SAFETY: lectura sobre fd válido propiedad del AsyncFd.
+        let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
+        if r >= 0 {
+            return Ok(r as usize);
+        }
+        let err = std::io::Error::last_os_error();
+        if err.kind() == std::io::ErrorKind::WouldBlock {
+            guard.clear_ready();
+            continue;
+        }
+        return Err(err);
+    }
+}
+
+async fn async_write_all(
+    afd: &AsyncFd<std::os::fd::OwnedFd>,
+    mut buf: &[u8],
+) -> std::io::Result<()> {
+    while !buf.is_empty() {
+        let mut guard = afd.writable().await?;
+        let fd = afd.as_raw_fd();
+        // SAFETY: escritura sobre fd válido propiedad del AsyncFd.
+        let r = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) };
+        if r > 0 {
+            buf = &buf[r as usize..];
+            continue;
+        }
+        if r == 0 {
+            return Err(std::io::Error::new(
+                std::io::ErrorKind::WriteZero,
+                "write 0",
+            ));
+        }
+        let err = std::io::Error::last_os_error();
+        if err.kind() == std::io::ErrorKind::WouldBlock {
+            guard.clear_ready();
+            continue;
+        }
+        return Err(err);
+    }
+    Ok(())
+}
+
+fn set_nonblocking(fd: RawFd) {
+    // SAFETY: fcntl con F_SETFL es seguro para fds válidos.
+    unsafe {
+        let flags = libc::fcntl(fd, libc::F_GETFL, 0);
+        if flags >= 0 {
+            libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
+        }
+    }
+}
+
+// Extension trait para abstraer la API de OwnedFd entre versiones (compat).
+trait OwnedFdFromRawCompat: Sized {
+    unsafe fn from_raw_fd_compat(fd: RawFd) -> Self;
+}
+
+impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
+    unsafe fn from_raw_fd_compat(fd: RawFd) -> Self {
+        use std::os::fd::FromRawFd;
+        // SAFETY: el caller transfiere ownership de `fd` a la `OwnedFd`.
+        unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
+    }
+}
+
+// Re-export para que el unused warning del AsRawFd se calle si no se usa.
+#[allow(dead_code)]
+fn _keep_raw(_: &dyn AsRawFd) {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use brahman_card::Payload;
+    use ente_incarnate::IncarnatorConfig;
+    use shipote_card::{CommandRef, DiscernPolicy, FlowEdge, PipelineSpec, WorkspaceId};
+
+    fn cmd(label: &str, exec: &str, argv: &[&str]) -> CommandRef {
+        CommandRef {
+            label: label.into(),
+            payload: Payload::Native {
+                exec: exec.into(),
+                argv: argv.iter().map(|s| s.to_string()).collect(),
+                envp: vec![],
+            },
+            soma: Default::default(),
+            flows: Default::default(),
+            supervision: brahman_card::Supervision::OneShot,
+        }
+    }
+
+    #[tokio::test]
+    async fn pipeline_isolated_echo_to_cat_runs() {
+        let spec = PipelineSpec {
+            label: "echo-cat".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![
+                cmd("p1", "/bin/echo", &["hola pipeline aislado"]),
+                cmd("p2", "/bin/cat", &[]),
+            ],
+            edges: vec![FlowEdge {
+                from: 0,
+                from_output: "stdout".into(),
+                to: 1,
+                to_input: "stdin".into(),
+            }],
+            discern: DiscernPolicy::default(),
+        };
+        let disc = Arc::new(DiscernPipeline::default_pipeline());
+        let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+        let launch = run_pipeline(&spec, "ws", false, disc, inc).await.unwrap();
+        assert_eq!(launch.command_pids.len(), 2);
+        // Cosecha.
+        for (_, pid) in &launch.command_pids {
+            let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+        }
+    }
+
+    #[tokio::test]
+    async fn pipeline_isolated_with_tap_captures_discernment() {
+        let spec = PipelineSpec {
+            label: "json-cat".into(),
+            workspace: WorkspaceId::new(),
+            nodes: vec![
+                cmd("p1", "/bin/echo", &["{\"hello\": 1}"]),
+                cmd("p2", "/bin/cat", &[]),
+            ],
+            edges: vec![FlowEdge {
+                from: 0,
+                from_output: "stdout".into(),
+                to: 1,
+                to_input: "stdin".into(),
+            }],
+            discern: DiscernPolicy {
+                sample_bytes: 4096,
+                enrich_producer: true,
+            },
+        };
+        let disc = Arc::new(DiscernPipeline::default_pipeline());
+        let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+        let launch = run_pipeline(&spec, "ws", true, disc, inc).await.unwrap();
+        assert_eq!(launch.edge_discernments.len(), 1);
+        let d = &launch.edge_discernments[0];
+        let dis = d.discernment.as_ref().expect("discernment present");
+        assert_eq!(dis.mime.as_deref(), Some("application/json"));
+        // Cosecha.
+        for (_, pid) in &launch.command_pids {
+            let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+        }
+    }
+}
diff --git a/crates/modules/shipote/shipote-discern/Cargo.toml b/crates/modules/shipote/shipote-discern/Cargo.toml
new file mode 100644
index 0000000..7e52da3
--- /dev/null
+++ b/crates/modules/shipote/shipote-discern/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "shipote-discern"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Discernidor de contenido sobre buffers: MIME, codificación, parser hints. Compartible con file_explorer y nouser."
+
+[dependencies]
+brahman-card = { path = "../../../core/brahman-card" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+toml = { workspace = true }
diff --git a/crates/modules/shipote/shipote-discern/src/lib.rs b/crates/modules/shipote/shipote-discern/src/lib.rs
new file mode 100644
index 0000000..f26344d
--- /dev/null
+++ b/crates/modules/shipote/shipote-discern/src/lib.rs
@@ -0,0 +1,307 @@
+//! `shipote-discern` — detección de tipo de contenido sobre buffers.
+//!
+//! Trait + pipeline + discerners default. Devuelve un [`Discernment`] con
+//! `TypeRef` consistente con el broker, confidence, MIME y un `lens` hint
+//! para UIs (reusa el espíritu del `dominant_lens` de nouser).
+
+#![forbid(unsafe_code)]
+
+use brahman_card::TypeRef;
+
+#[derive(Debug, Clone)]
+pub struct Hint<'a> {
+    pub path: Option<&'a str>,
+    pub size_total: Option<u64>,
+}
+
+#[derive(Debug, Clone)]
+pub struct Discernment {
+    pub ty: TypeRef,
+    pub confidence: f32,
+    pub mime: Option<String>,
+    pub lens: Option<String>,
+}
+
+pub trait Discerner: Send + Sync {
+    fn name(&self) -> &str;
+    fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option<Discernment>;
+}
+
+pub struct DiscernPipeline {
+    discerners: Vec<Box<dyn Discerner>>,
+}
+
+impl DiscernPipeline {
+    pub fn new() -> Self {
+        Self { discerners: Vec::new() }
+    }
+
+    /// Pipeline con los discerners default. Orden importa: el primer match
+    /// con confidence ≥ `accept_threshold` corta.
+    pub fn default_pipeline() -> Self {
+        let mut p = Self::new();
+        p.push(Box::new(MagicBytes));
+        // CardProbe antes que JsonProbe: una Card es JSON, pero queremos el
+        // TypeRef más específico cuando aplique.
+        p.push(Box::new(CardProbe));
+        p.push(Box::new(JsonProbe));
+        p.push(Box::new(TomlProbe));
+        p.push(Box::new(Utf8Probe));
+        p
+    }
+
+    pub fn push(&mut self, d: Box<dyn Discerner>) {
+        self.discerners.push(d);
+    }
+
+    /// Recorre los discerners y devuelve el primer Discernment con
+    /// confidence ≥ 0.5, o el más confidente si ninguno alcanza el umbral.
+    pub fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option<Discernment> {
+        let mut best: Option<Discernment> = None;
+        for d in &self.discerners {
+            if let Some(r) = d.discern(sample, hint) {
+                if r.confidence >= 0.9 {
+                    return Some(r);
+                }
+                best = match best {
+                    Some(prev) if prev.confidence >= r.confidence => Some(prev),
+                    _ => Some(r),
+                };
+            }
+        }
+        best
+    }
+}
+
+impl Default for DiscernPipeline {
+    fn default() -> Self {
+        Self::default_pipeline()
+    }
+}
+
+// =====================================================================
+// Discerners
+// =====================================================================
+
+/// Magic-bytes para formatos comunes. Confidence alta cuando hay match.
+pub struct MagicBytes;
+
+impl Discerner for MagicBytes {
+    fn name(&self) -> &str { "magic-bytes" }
+
+    fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
+        let d = |ty: &str, mime: &str, lens: Option<&str>| Discernment {
+            ty: TypeRef::Primitive { name: ty.into() },
+            confidence: 0.99,
+            mime: Some(mime.into()),
+            lens: lens.map(String::from),
+        };
+        match s {
+            x if x.starts_with(&[0x89, b'P', b'N', b'G']) => Some(d("png", "image/png", Some("gallery"))),
+            x if x.starts_with(&[0xFF, 0xD8, 0xFF]) => Some(d("jpeg", "image/jpeg", Some("gallery"))),
+            x if x.starts_with(b"%PDF-") => Some(d("pdf", "application/pdf", Some("reader"))),
+            x if x.starts_with(&[0x7F, b'E', b'L', b'F']) => Some(d("elf", "application/x-executable", None)),
+            x if x.starts_with(&[0x00, 0x61, 0x73, 0x6D]) => Some(d("wasm", "application/wasm", None)),
+            x if x.starts_with(&[0x1F, 0x8B]) => Some(d("gzip", "application/gzip", None)),
+            x if x.starts_with(b"PK\x03\x04") || x.starts_with(b"PK\x05\x06") => {
+                Some(d("zip", "application/zip", None))
+            }
+            x if x.starts_with(b"GIF87a") || x.starts_with(b"GIF89a") => {
+                Some(d("gif", "image/gif", Some("gallery")))
+            }
+            _ => None,
+        }
+    }
+}
+
+/// JSON: parsea el inicio. No requiere parsearlo entero; con que arranque
+/// con `{`/`[` y haga progreso cuenta.
+pub struct JsonProbe;
+
+impl Discerner for JsonProbe {
+    fn name(&self) -> &str { "json" }
+
+    fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
+        let trimmed = trim_left(s);
+        let first = *trimmed.first()?;
+        if first != b'{' && first != b'[' {
+            return None;
+        }
+        // Intento parsear tal cual; si falla por truncated, igualmente confidence media.
+        let txt = std::str::from_utf8(trimmed).ok()?;
+        match serde_json::from_str::<serde_json::Value>(txt) {
+            Ok(_) => Some(Discernment {
+                ty: TypeRef::Primitive { name: "json".into() },
+                confidence: 0.95,
+                mime: Some("application/json".into()),
+                lens: Some("tree".into()),
+            }),
+            Err(_) => Some(Discernment {
+                ty: TypeRef::Primitive { name: "json".into() },
+                confidence: 0.6, // sample truncado
+                mime: Some("application/json".into()),
+                lens: Some("tree".into()),
+            }),
+        }
+    }
+}
+
+pub struct TomlProbe;
+
+impl Discerner for TomlProbe {
+    fn name(&self) -> &str { "toml" }
+
+    fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option<Discernment> {
+        let txt = std::str::from_utf8(s).ok()?;
+        // Heurística: presencia de `[seccion]` y/o `clave = valor` y extensión.
+        let looks_like = txt.lines().any(|l| {
+            let l = l.trim();
+            l.starts_with('[') && l.ends_with(']')
+        }) || txt.lines().any(|l| {
+            let l = l.trim();
+            !l.starts_with('#') && l.contains(" = ")
+        });
+        if !looks_like {
+            return None;
+        }
+        let confidence = if h.path.map_or(false, |p| p.ends_with(".toml")) {
+            0.95
+        } else {
+            0.55
+        };
+        // Si parsea, sube confidence.
+        let parsed = toml::from_str::<toml::Value>(txt).is_ok();
+        Some(Discernment {
+            ty: TypeRef::Primitive { name: "toml".into() },
+            confidence: if parsed { 0.93 } else { confidence },
+            mime: Some("application/toml".into()),
+            lens: Some("tree".into()),
+        })
+    }
+}
+
+/// Si el JSON parsea como Card, lo emite como Wit { brahman:card }.
+pub struct CardProbe;
+
+impl Discerner for CardProbe {
+    fn name(&self) -> &str { "card" }
+
+    fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option<Discernment> {
+        let trimmed = trim_left(s);
+        if trimmed.first()? != &b'{' {
+            return None;
+        }
+        let txt = std::str::from_utf8(trimmed).ok()?;
+        let v: serde_json::Value = serde_json::from_str(txt).ok()?;
+        let obj = v.as_object()?;
+        if obj.contains_key("schema_version") && obj.contains_key("id") && obj.contains_key("payload") {
+            Some(Discernment {
+                ty: TypeRef::Wit {
+                    package: "brahman:card".into(),
+                    interface: None,
+                    name: "card".into(),
+                },
+                confidence: 0.97,
+                mime: Some("application/json".into()),
+                lens: Some("card".into()),
+            })
+        } else {
+            None
+        }
+    }
+}
+
+/// Texto UTF-8 plano. Fallback de baja confidence.
+pub struct Utf8Probe;
+
+impl Discerner for Utf8Probe {
+    fn name(&self) -> &str { "utf8" }
+
+    fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option<Discernment> {
+        if s.is_empty() {
+            return None;
+        }
+        let valid = std::str::from_utf8(s).is_ok();
+        if !valid {
+            return None;
+        }
+        // Detectar binario disfrazado: bytes de control fuera de \t\n\r.
+        let suspicious = s.iter().filter(|&&b| b < 0x09 || (b > 0x0D && b < 0x20)).count();
+        if suspicious * 100 / s.len().max(1) > 5 {
+            return None;
+        }
+        let lens = h.path.and_then(|p| {
+            if p.ends_with(".md") { Some("markdown") }
+            else if p.ends_with(".rs") || p.ends_with(".py") || p.ends_with(".go") || p.ends_with(".js") || p.ends_with(".ts") {
+                Some("code")
+            } else { None }
+        }).map(String::from);
+        Some(Discernment {
+            ty: TypeRef::Primitive { name: "text".into() },
+            confidence: 0.5,
+            mime: Some("text/plain; charset=utf-8".into()),
+            lens,
+        })
+    }
+}
+
+fn trim_left(s: &[u8]) -> &[u8] {
+    let mut i = 0;
+    while i < s.len() && (s[i] == b' ' || s[i] == b'\t' || s[i] == b'\n' || s[i] == b'\r') {
+        i += 1;
+    }
+    &s[i..]
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn discern(sample: &[u8]) -> Option<Discernment> {
+        DiscernPipeline::default_pipeline().discern(sample, &Hint { path: None, size_total: None })
+    }
+
+    #[test]
+    fn png_detected() {
+        let r = discern(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]).unwrap();
+        assert_eq!(r.mime.as_deref(), Some("image/png"));
+        assert!(r.confidence > 0.9);
+    }
+
+    #[test]
+    fn json_detected() {
+        let r = discern(b"{\"hello\": 1}").unwrap();
+        assert_eq!(r.mime.as_deref(), Some("application/json"));
+    }
+
+    #[test]
+    fn card_wins_over_plain_json() {
+        let payload = br#"{"schema_version":1,"id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","label":"x","payload":{"Virtual":null},"supervision":"OneShot"}"#;
+        let r = discern(payload).unwrap();
+        match r.ty {
+            TypeRef::Wit { ref package, .. } => assert_eq!(package, "brahman:card"),
+            _ => panic!("expected card"),
+        }
+    }
+
+    #[test]
+    fn utf8_text_fallback() {
+        let r = discern(b"hello world\nthis is text").unwrap();
+        // Puede ser detected as toml (= heurística) o text. Ambos son aceptables, sólo aseguro algo razonable.
+        assert!(r.mime.is_some());
+    }
+
+    #[test]
+    fn binary_rejected_by_utf8() {
+        let mut bytes = vec![0u8; 100];
+        bytes[0] = 0x00;
+        bytes[1] = 0x01;
+        bytes[2] = 0x02;
+        let r = DiscernPipeline::default_pipeline().discern(&bytes, &Hint { path: None, size_total: None });
+        // Tras Utf8Probe rechazar, no hay match → None.
+        // Si por casualidad otro discerner mata antes, también es OK.
+        if let Some(r) = r {
+            assert_ne!(r.mime.as_deref(), Some("text/plain; charset=utf-8"));
+        }
+    }
+}
diff --git a/crates/modules/shipote/shipote-protocol/Cargo.toml b/crates/modules/shipote/shipote-protocol/Cargo.toml
new file mode 100644
index 0000000..45000ef
--- /dev/null
+++ b/crates/modules/shipote/shipote-protocol/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "shipote-protocol"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Wire protocol entre shipote-daemon y clientes (cli/gui). Postcard length-prefixed sobre Unix socket."
+
+[dependencies]
+shipote-card = { path = "../shipote-card" }
+brahman-card = { path = "../../../core/brahman-card" }
+serde = { workspace = true }
+postcard = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+ulid = { workspace = true }
+nix = { workspace = true }
diff --git a/crates/modules/shipote/shipote-protocol/src/lib.rs b/crates/modules/shipote/shipote-protocol/src/lib.rs
new file mode 100644
index 0000000..6e18842
--- /dev/null
+++ b/crates/modules/shipote/shipote-protocol/src/lib.rs
@@ -0,0 +1,290 @@
+//! `shipote-protocol` — wire daemon ↔ cliente (cli/gui).
+//!
+//! Framing: u32 BE length-prefix + payload postcard. Mismo patrón que
+//! `ente-bus`/`brahman-handshake` para que clientes existentes compartan
+//! reader/writer helpers si quieren.
+
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+use shipote_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
+use std::path::PathBuf;
+use thiserror::Error;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::UnixStream;
+use ulid::Ulid;
+
+pub const DEFAULT_SOCK_NAME: &str = "shipote.sock";
+pub const MAX_FRAME: usize = 1 << 20;
+
+// =====================================================================
+// Mensajes
+// =====================================================================
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Request {
+    /// Health-check.
+    Ping,
+
+    /// Crear un workspace nuevo.
+    WorkspaceCreate { spec: WorkspaceSpec },
+
+    /// Listar todos los workspaces vivos.
+    WorkspaceList,
+
+    /// Detener un workspace y reapear sus comandos.
+    WorkspaceStop { id: WorkspaceId },
+
+    /// Ejecutar un comando one-shot dentro de un workspace existente.
+    Run {
+        workspace: WorkspaceId,
+        exec: String,
+        argv: Vec<String>,
+        envp: Vec<(String, String)>,
+    },
+
+    /// Lanzar un Pipeline completo dentro de un workspace.
+    PipelineRun {
+        spec: PipelineSpec,
+        /// Si `true`, el daemon interpone un tap entre productor y
+        /// consumidor de cada FlowEdge, sampleando los primeros bytes
+        /// y discerniendo el TypeRef.
+        tap: bool,
+    },
+
+    /// Discernir un buffer ad-hoc (sin workspace). Útil para `shipote discern <file>`.
+    Discern { sample: Vec<u8>, hint_path: Option<PathBuf> },
+
+    /// Capacidades runtime del kernel/proceso del daemon.
+    Capabilities,
+
+    /// Listar comandos vivos+pasados de un workspace.
+    CommandList { workspace: shipote_card::WorkspaceId },
+
+    /// Tail del log capturado para un comando.
+    CommandLogs {
+        workspace: shipote_card::WorkspaceId,
+        command: Ulid,
+        tail_bytes: usize,
+    },
+
+    /// Guardar (o reemplazar) un PipelineSpec bajo un nombre.
+    PipelineSave { name: String, spec: PipelineSpec },
+
+    /// Listar nombres de pipelines guardados.
+    PipelineSavedList,
+
+    /// Eliminar un pipeline guardado.
+    PipelineDrop { name: String },
+
+    /// Ejecutar un pipeline guardado.
+    PipelineRunSaved { name: String, tap: bool },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Response {
+    Pong,
+
+    WorkspaceCreated {
+        id: WorkspaceId,
+        warnings: Vec<String>,
+    },
+
+    WorkspaceList {
+        items: Vec<WorkspaceSummary>,
+    },
+
+    WorkspaceStopped {
+        id: WorkspaceId,
+        reaped: u32,
+    },
+
+    RunStarted {
+        workspace: WorkspaceId,
+        command_id: Ulid,
+        pid: i32,
+    },
+
+    PipelineStarted {
+        pipeline: Ulid,
+        command_pids: Vec<(String, i32)>,
+        /// Discernments por edge cuando tap=true. Vacío sin tap.
+        edges: Vec<EdgeDiscernmentInfo>,
+    },
+
+    Discernment {
+        ty: String,
+        confidence: f32,
+        mime: Option<String>,
+        lens: Option<String>,
+    },
+
+    Capabilities {
+        kernel_version: (u32, u32, u32),
+        user_ns: String,
+        cgroup_v2: String,
+        cgroup_delegated: bool,
+        has_cap_sys_admin: bool,
+    },
+
+    CommandList {
+        items: Vec<CommandInfo>,
+    },
+
+    CommandLogs {
+        bytes: Vec<u8>,
+    },
+
+    PipelineSaved {
+        name: String,
+    },
+
+    PipelineSavedList {
+        names: Vec<String>,
+    },
+
+    PipelineDropped {
+        name: String,
+        existed: bool,
+    },
+
+    Error {
+        message: String,
+    },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CommandInfo {
+    pub id: Ulid,
+    pub label: String,
+    pub pid: i32,
+    pub alive: bool,
+    pub exit_status: Option<i32>,
+    pub log_bytes: u64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct EdgeDiscernmentInfo {
+    pub from_label: String,
+    pub from_output: String,
+    pub to_label: String,
+    pub to_input: String,
+    /// `Some(ty)` si el discerner detectó algo. `None` si no hubo data
+    /// suficiente o no matcheó ningún discerner.
+    pub ty: Option<String>,
+    pub mime: Option<String>,
+    pub lens: Option<String>,
+    pub confidence: f32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WorkspaceSummary {
+    pub id: WorkspaceId,
+    pub label: String,
+    pub commands: u32,
+    pub uptime_ms: u64,
+}
+
+// =====================================================================
+// Errores
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum ProtocolError {
+    #[error("frame oversize: {0} bytes (max {MAX_FRAME})")]
+    FrameOversize(usize),
+    #[error("io: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("postcard: {0}")]
+    Postcard(#[from] postcard::Error),
+    #[error("connection closed")]
+    Closed,
+}
+
+// =====================================================================
+// Framing helpers
+// =====================================================================
+
+pub async fn write_frame<T: Serialize>(stream: &mut UnixStream, msg: &T) -> Result<(), ProtocolError> {
+    let bytes = postcard::to_allocvec(msg)?;
+    if bytes.len() > MAX_FRAME {
+        return Err(ProtocolError::FrameOversize(bytes.len()));
+    }
+    let len = (bytes.len() as u32).to_be_bytes();
+    stream.write_all(&len).await?;
+    stream.write_all(&bytes).await?;
+    stream.flush().await?;
+    Ok(())
+}
+
+pub async fn read_frame<T: for<'de> Deserialize<'de>>(
+    stream: &mut UnixStream,
+) -> Result<T, ProtocolError> {
+    let mut len_buf = [0u8; 4];
+    stream.read_exact(&mut len_buf).await.map_err(|e| {
+        if e.kind() == std::io::ErrorKind::UnexpectedEof {
+            ProtocolError::Closed
+        } else {
+            ProtocolError::Io(e)
+        }
+    })?;
+    let len = u32::from_be_bytes(len_buf) as usize;
+    if len > MAX_FRAME {
+        return Err(ProtocolError::FrameOversize(len));
+    }
+    let mut buf = vec![0u8; len];
+    stream.read_exact(&mut buf).await?;
+    Ok(postcard::from_bytes(&buf)?)
+}
+
+/// Path canónico del socket del daemon: `$XDG_RUNTIME_DIR/shipote.sock`,
+/// fallback `/run/user/$UID/shipote.sock`, fallback `/tmp/shipote-$UID.sock`.
+pub fn default_socket_path() -> PathBuf {
+    if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
+        return PathBuf::from(xdg).join(DEFAULT_SOCK_NAME);
+    }
+    let uid = nix::unistd::getuid().as_raw();
+    let p = PathBuf::from(format!("/run/user/{uid}"));
+    if p.exists() {
+        return p.join(DEFAULT_SOCK_NAME);
+    }
+    PathBuf::from(format!("/tmp/shipote-{uid}.sock"))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn ping_roundtrip() {
+        let bytes = postcard::to_allocvec(&Request::Ping).unwrap();
+        let back: Request = postcard::from_bytes(&bytes).unwrap();
+        assert!(matches!(back, Request::Ping));
+    }
+
+    #[test]
+    fn workspace_create_roundtrip() {
+        let req = Request::WorkspaceCreate {
+            spec: WorkspaceSpec {
+                label: "demo".into(),
+                soma: Default::default(),
+                permissions: Default::default(),
+                ttl: None,
+                flow_dirs: vec![],
+                on_exit: shipote_card::ExitPolicy::Reap,
+            },
+        };
+        let bytes = postcard::to_allocvec(&req).unwrap();
+        let back: Request = postcard::from_bytes(&bytes).unwrap();
+        match back {
+            Request::WorkspaceCreate { spec } => assert_eq!(spec.label, "demo"),
+            _ => panic!("wrong variant"),
+        }
+    }
+
+    #[test]
+    fn default_socket_path_uses_runtime_dir() {
+        let p = default_socket_path();
+        assert!(p.to_string_lossy().ends_with("shipote.sock"));
+    }
+}
diff --git a/crates/modules/ui_engine/libs/providers/fs/Cargo.toml b/crates/modules/ui_engine/libs/providers/fs/Cargo.toml
index 4812731..265a3d2 100644
--- a/crates/modules/ui_engine/libs/providers/fs/Cargo.toml
+++ b/crates/modules/ui_engine/libs/providers/fs/Cargo.toml
@@ -3,10 +3,11 @@ name = "yahweh-provider-fs"
 version = { workspace = true }
 edition = { workspace = true }
 license = { workspace = true }
-description = "DataProvider de filesystem local."
+description = "DataProvider de filesystem local con discernimiento de contenido (shipote-discern)."
 
 [dependencies]
 yahweh-core = { workspace = true }
 async-trait = { workspace = true }
 tokio = { workspace = true }
 notify = { workspace = true }
+shipote-discern = { path = "../../../../../modules/shipote/shipote-discern" }
diff --git a/crates/modules/ui_engine/libs/providers/fs/src/lib.rs b/crates/modules/ui_engine/libs/providers/fs/src/lib.rs
index 3cfea50..42946cf 100644
--- a/crates/modules/ui_engine/libs/providers/fs/src/lib.rs
+++ b/crates/modules/ui_engine/libs/providers/fs/src/lib.rs
@@ -3,16 +3,45 @@
 //! `std::fs::read_dir` y leyendo archivos a `Vec<u8>` via `tokio::io`.
 
 use async_trait::async_trait;
+use shipote_discern::{DiscernPipeline, Hint};
 use std::fs;
-use std::io::Cursor;
+use std::io::{Cursor, Read};
 use std::path::Path;
 use std::pin::Pin;
+use std::sync::Arc;
 use tokio::io::{AsyncRead, AsyncWrite};
 use yahweh_core::{DataProvider, DisplayType, EntityNode};
 
 pub const PROVIDER_ID: &str = "local_fs";
 
-pub struct FileDataProvider;
+/// Bytes que samplea el discerner por archivo. 4 KiB cubre headers de
+/// formatos comunes (PNG, ELF, JSON/TOML hasta una clave de profundidad
+/// razonable) sin saturar I/O al expandir un directorio.
+const DISCERN_SAMPLE_BYTES: usize = 4096;
+
+/// Tamaño máximo de archivo que sampleamos. Archivos más grandes se
+/// discernen igual via los primeros 4 KiB: el `seek/read` siempre lee
+/// head, y el costo es O(SAMPLE) sin importar el size total.
+/// Mantenemos esta constante por documentación; no se usa para skipear.
+const _DISCERN_SAMPLE_DOC: () = ();
+
+pub struct FileDataProvider {
+    discerner: Arc<DiscernPipeline>,
+}
+
+impl FileDataProvider {
+    pub fn new() -> Self {
+        Self {
+            discerner: Arc::new(DiscernPipeline::default_pipeline()),
+        }
+    }
+}
+
+impl Default for FileDataProvider {
+    fn default() -> Self {
+        Self::new()
+    }
+}
 
 #[async_trait]
 impl DataProvider for FileDataProvider {
@@ -32,17 +61,21 @@ impl DataProvider for FileDataProvider {
                     .unwrap_or_default()
                     .to_string_lossy()
                     .into_owned();
-                let display_type = if path.is_dir() {
-                    DisplayType::Folder
+                let is_dir = path.is_dir();
+                let display_type = if is_dir { DisplayType::Folder } else { DisplayType::File };
+
+                // Discernimos sólo archivos. Folders no tienen MIME útil.
+                let mime_type = if is_dir {
+                    None
                 } else {
-                    DisplayType::File
+                    discern_head(&path, &self.discerner)
                 };
 
                 children.push(EntityNode {
                     id: path.to_string_lossy().into_owned(),
                     name,
                     display_type,
-                    mime_type: None,
+                    mime_type,
                 });
             }
         }
@@ -65,3 +98,22 @@ impl DataProvider for FileDataProvider {
         Err("Escritura en streaming no implementada para FS".to_string())
     }
 }
+
+/// Lee el head del archivo y lo pasa por el DiscernPipeline. Devuelve el
+/// MIME detectado (si alguno) o `None` si no hubo match.
+///
+/// Sync intencional: estamos dentro del runtime que ya es async, pero la
+/// lectura es de tamaño fijo (4 KiB) y va a page cache; el costo de
+/// `tokio::fs` no compensaría para esto.
+fn discern_head(path: &Path, discerner: &DiscernPipeline) -> Option<String> {
+    let mut buf = vec![0u8; DISCERN_SAMPLE_BYTES];
+    let mut f = fs::File::open(path).ok()?;
+    let n = f.read(&mut buf).ok()?;
+    buf.truncate(n);
+    let path_str = path.to_str();
+    let hint = Hint {
+        path: path_str,
+        size_total: None,
+    };
+    discerner.discern(&buf, &hint).and_then(|d| d.mime)
+}
diff --git a/crates/shared/ente-incarnate/Cargo.toml b/crates/shared/ente-incarnate/Cargo.toml
new file mode 100644
index 0000000..3e2a00c
--- /dev/null
+++ b/crates/shared/ente-incarnate/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "ente-incarnate"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Rutina extraída del Init para encarnar Cards en procesos aislados (clone+ns+cgroup+rlimits). Reusable por cualquier supervisor — no implica ser PID 1."
+
+[dependencies]
+brahman-card = { path = "../../core/brahman-card" }
+nix = { workspace = true }
+libc = { workspace = true }
+anyhow = { workspace = true }
+thiserror = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/shared/ente-incarnate/src/caps.rs b/crates/shared/ente-incarnate/src/caps.rs
new file mode 100644
index 0000000..29a6791
--- /dev/null
+++ b/crates/shared/ente-incarnate/src/caps.rs
@@ -0,0 +1,214 @@
+//! Detección runtime de capacidades del kernel/proceso para aislamiento.
+//!
+//! Esto NO se cachea entre instancias — sysctls pueden cambiar entre boot, y
+//! cgroup delegation depende del proceso concreto. Cada `Incarnator::new`
+//! hace su detección al construirse.
+
+use std::path::{Path, PathBuf};
+
+#[derive(Debug, Clone)]
+pub struct CapabilitySet {
+    pub kernel_version: (u32, u32, u32),
+    pub has_cap_sys_admin: bool,
+    pub user_ns: UserNsStatus,
+    pub cgroup_v2: CgroupStatus,
+    pub cgroup_delegated: bool,
+    pub max_user_namespaces: Option<u64>,
+    pub our_cgroup: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum UserNsStatus {
+    Allowed,
+    DisabledBySysctl,
+    RestrictedByLsm,
+    Unknown,
+}
+
+impl UserNsStatus {
+    pub fn is_allowed(&self) -> bool {
+        matches!(self, UserNsStatus::Allowed)
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum CgroupStatus {
+    Unified,
+    Hybrid,
+    Legacy,
+    NotMounted,
+}
+
+impl CapabilitySet {
+    pub fn detect() -> Self {
+        Self {
+            kernel_version: detect_kernel_version().unwrap_or((0, 0, 0)),
+            has_cap_sys_admin: detect_cap_sys_admin(),
+            user_ns: detect_user_ns(),
+            cgroup_v2: detect_cgroup_status(),
+            cgroup_delegated: detect_cgroup_delegated(),
+            max_user_namespaces: read_u64("/proc/sys/user/max_user_namespaces"),
+            our_cgroup: detect_our_cgroup(),
+        }
+    }
+
+    /// ¿Podemos crear el namespace `ns`?
+    /// Reglas:
+    /// - user → necesita user_ns Allowed (o ya tener CAP_SYS_ADMIN, en cuyo caso no se crea uno nuevo).
+    /// - resto → CAP_SYS_ADMIN, o crearlos junto con user ns nuevo.
+    pub fn can_create_ns(&self, kind: NsKind) -> bool {
+        match kind {
+            NsKind::User => self.user_ns.is_allowed() || self.has_cap_sys_admin,
+            _ => {
+                self.has_cap_sys_admin
+                    || (self.user_ns.is_allowed() && self.max_user_namespaces.unwrap_or(0) > 0)
+            }
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum NsKind {
+    Mount,
+    Pid,
+    Net,
+    Uts,
+    Ipc,
+    User,
+    Cgroup,
+}
+
+impl NsKind {
+    pub fn name(self) -> &'static str {
+        match self {
+            NsKind::Mount => "mount",
+            NsKind::Pid => "pid",
+            NsKind::Net => "net",
+            NsKind::Uts => "uts",
+            NsKind::Ipc => "ipc",
+            NsKind::User => "user",
+            NsKind::Cgroup => "cgroup",
+        }
+    }
+}
+
+fn detect_kernel_version() -> Option<(u32, u32, u32)> {
+    let s = std::fs::read_to_string("/proc/sys/kernel/osrelease").ok()?;
+    let head = s.split(|c: char| !c.is_ascii_digit() && c != '.').next()?;
+    let mut it = head.split('.');
+    let a = it.next()?.parse().ok()?;
+    let b = it.next()?.parse().ok()?;
+    let c = it.next().and_then(|x| x.parse().ok()).unwrap_or(0);
+    Some((a, b, c))
+}
+
+fn detect_cap_sys_admin() -> bool {
+    // euid 0 implica caps por default. Modo simple: si euid==0, asumimos CAP_SYS_ADMIN.
+    // Podríamos parsear /proc/self/status > CapEff, pero para nuestros usos el
+    // discriminador útil es root vs no-root.
+    nix::unistd::geteuid().is_root()
+}
+
+fn detect_user_ns() -> UserNsStatus {
+    // Sysctl tradicional Debian/Ubuntu pre-24.
+    if let Some(v) = read_u64("/proc/sys/kernel/unprivileged_userns_clone") {
+        if v == 0 {
+            return UserNsStatus::DisabledBySysctl;
+        }
+    }
+    // AppArmor restriction (Ubuntu 24+). 1 = restringido, 2 = restricción aplicada.
+    if let Some(v) = read_u64("/proc/sys/kernel/apparmor_restrict_unprivileged_userns") {
+        if v >= 1 {
+            return UserNsStatus::RestrictedByLsm;
+        }
+    }
+    if let Some(0) = read_u64("/proc/sys/user/max_user_namespaces") {
+        return UserNsStatus::DisabledBySysctl;
+    }
+    UserNsStatus::Allowed
+}
+
+fn detect_cgroup_status() -> CgroupStatus {
+    // /sys/fs/cgroup montado como cgroup2 → unified.
+    let mounts = match std::fs::read_to_string("/proc/self/mountinfo") {
+        Ok(s) => s,
+        Err(_) => return CgroupStatus::NotMounted,
+    };
+    let mut has_v2 = false;
+    let mut has_v1 = false;
+    for line in mounts.lines() {
+        // formato: ... - <fstype> <source> <opts>
+        let parts: Vec<&str> = line.split(" - ").collect();
+        if parts.len() < 2 {
+            continue;
+        }
+        let tail = parts[1];
+        let fields: Vec<&str> = tail.split_whitespace().collect();
+        if fields.is_empty() {
+            continue;
+        }
+        match fields[0] {
+            "cgroup2" => has_v2 = true,
+            "cgroup" => has_v1 = true,
+            _ => {}
+        }
+    }
+    match (has_v2, has_v1) {
+        (true, false) => CgroupStatus::Unified,
+        (true, true) => CgroupStatus::Hybrid,
+        (false, true) => CgroupStatus::Legacy,
+        (false, false) => CgroupStatus::NotMounted,
+    }
+}
+
+fn detect_our_cgroup() -> Option<PathBuf> {
+    let s = std::fs::read_to_string("/proc/self/cgroup").ok()?;
+    let rel = s.lines().find_map(|l| l.strip_prefix("0::"))?.trim();
+    let abs = if rel == "/" {
+        PathBuf::from("/sys/fs/cgroup")
+    } else {
+        PathBuf::from(format!("/sys/fs/cgroup{rel}"))
+    };
+    Some(abs)
+}
+
+fn detect_cgroup_delegated() -> bool {
+    // Heurística: ¿podemos escribir cgroup.subtree_control en nuestro cgroup
+    // o crear subdirectorios? En cgroup v2 con Delegate=yes, el dueño es el uid
+    // del usuario y `access(W_OK)` sobre el directorio devuelve OK.
+    let Some(p) = detect_our_cgroup() else { return false };
+    use nix::unistd::{access, AccessFlags};
+    access(&p, AccessFlags::W_OK).is_ok()
+}
+
+fn read_u64(path: &str) -> Option<u64> {
+    let s = std::fs::read_to_string(Path::new(path)).ok()?;
+    s.trim().parse().ok()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn detect_does_not_panic() {
+        let _ = CapabilitySet::detect();
+    }
+
+    #[test]
+    fn ns_kind_names_unique() {
+        let names = [
+            NsKind::Mount.name(),
+            NsKind::Pid.name(),
+            NsKind::Net.name(),
+            NsKind::Uts.name(),
+            NsKind::Ipc.name(),
+            NsKind::User.name(),
+            NsKind::Cgroup.name(),
+        ];
+        let mut sorted = names.to_vec();
+        sorted.sort();
+        sorted.dedup();
+        assert_eq!(sorted.len(), names.len());
+    }
+}
diff --git a/crates/shared/ente-incarnate/src/cgroup.rs b/crates/shared/ente-incarnate/src/cgroup.rs
new file mode 100644
index 0000000..580d478
--- /dev/null
+++ b/crates/shared/ente-incarnate/src/cgroup.rs
@@ -0,0 +1,91 @@
+//! Resolución y creación de cgroups v2 para el hijo.
+
+use crate::error::IncarnateError;
+use brahman_card::CgroupSpec;
+use std::path::PathBuf;
+
+/// Cgroup actual del proceso que llama. Lo usamos como prefijo para paths
+/// declarados relativos en `CgroupSpec.path`.
+pub fn current_cgroup() -> Option<String> {
+    let s = std::fs::read_to_string("/proc/self/cgroup").ok()?;
+    s.lines()
+        .find_map(|l| l.strip_prefix("0::"))
+        .map(|s| s.trim().to_string())
+}
+
+/// Resuelve un path declarado contra la jerarquía real.
+pub fn resolve_cgroup_path(spec_path: &str) -> String {
+    if spec_path.is_empty() {
+        return String::new();
+    }
+    if spec_path.starts_with('/') {
+        return spec_path.to_string();
+    }
+    let trimmed = spec_path.trim_start_matches('/');
+    if let Some(cg) = current_cgroup() {
+        let base = if cg == "/" {
+            String::new()
+        } else {
+            cg.trim_end_matches('/').to_string()
+        };
+        format!("{base}/{trimmed}")
+    } else {
+        format!("/{trimmed}")
+    }
+}
+
+/// Crea el cgroup declarado y aplica weights. Devuelve el path absoluto
+/// resultante bajo `/sys/fs/cgroup`.
+pub fn ensure_cgroup(spec: &CgroupSpec) -> Result<PathBuf, IncarnateError> {
+    let rel = resolve_cgroup_path(&spec.path);
+    if rel.is_empty() {
+        return Err(IncarnateError::CgroupNotWritable {
+            path: PathBuf::from("(empty)"),
+        });
+    }
+    let abs = PathBuf::from(format!("/sys/fs/cgroup{}", rel));
+    std::fs::create_dir_all(&abs).map_err(|e| match e.kind() {
+        std::io::ErrorKind::PermissionDenied => IncarnateError::CgroupNotWritable { path: abs.clone() },
+        _ => IncarnateError::Io(e),
+    })?;
+    if let Some(w) = spec.cpu_weight {
+        let _ = std::fs::write(abs.join("cpu.weight"), format!("{w}\n"));
+    }
+    if let Some(w) = spec.io_weight {
+        // io.weight requiere "default <n>" en cgroup v2.
+        let _ = std::fs::write(abs.join("io.weight"), format!("default {w}\n"));
+    }
+    Ok(abs)
+}
+
+/// Mueve `pid` a `cgroup_abs/cgroup.procs`.
+pub fn move_to_cgroup(cgroup_abs: &std::path::Path, pid: nix::unistd::Pid) -> Result<(), IncarnateError> {
+    let procs = cgroup_abs.join("cgroup.procs");
+    std::fs::write(&procs, format!("{}\n", pid.as_raw())).map_err(|e| match e.kind() {
+        std::io::ErrorKind::PermissionDenied => IncarnateError::CgroupNotWritable {
+            path: procs.clone(),
+        },
+        _ => IncarnateError::Io(e),
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn absolute_path_passthrough() {
+        assert_eq!(resolve_cgroup_path("/foo/bar"), "/foo/bar");
+    }
+
+    #[test]
+    fn empty_returns_empty() {
+        assert_eq!(resolve_cgroup_path(""), "");
+    }
+
+    #[test]
+    fn relative_path_prefixed() {
+        let r = resolve_cgroup_path("shipote/ws-1");
+        assert!(r.ends_with("/shipote/ws-1") || r == "/shipote/ws-1");
+    }
+}
diff --git a/crates/shared/ente-incarnate/src/child.rs b/crates/shared/ente-incarnate/src/child.rs
new file mode 100644
index 0000000..30a2f09
--- /dev/null
+++ b/crates/shared/ente-incarnate/src/child.rs
@@ -0,0 +1,47 @@
+//! Helpers que corren EN el hijo post-clone, antes de execve.
+//!
+//! Reglas inviolables (la clausura de clone(2) corre en stack nuevo, COW):
+//!   - sólo syscalls async-signal-safe
+//!   - no `println!`/`tracing!`/cualquier I/O del runtime
+//!   - no allocator (vec/box/string)
+//!   - no Drop con efectos
+//!   - capturar sólo Copy o datos pre-construidos
+
+use brahman_card::ResourceLimits;
+
+/// SAFETY: invocada en el hijo post-clone, sólo libc.
+pub unsafe fn apply_rlimits(rl: &ResourceLimits) {
+    if let Some(mem) = rl.mem_bytes {
+        let lim = libc::rlimit {
+            rlim_cur: mem,
+            rlim_max: mem,
+        };
+        libc::setrlimit(libc::RLIMIT_AS, &lim);
+    }
+    if let Some(np) = rl.nproc {
+        let lim = libc::rlimit {
+            rlim_cur: np as u64,
+            rlim_max: np as u64,
+        };
+        libc::setrlimit(libc::RLIMIT_NPROC, &lim);
+    }
+    if let Some(nf) = rl.nofile {
+        let lim = libc::rlimit {
+            rlim_cur: nf as u64,
+            rlim_max: nf as u64,
+        };
+        libc::setrlimit(libc::RLIMIT_NOFILE, &lim);
+    }
+}
+
+/// SAFETY: idem. `MS_PRIVATE | MS_REC` sobre `/` para que mounts del hijo
+/// no se filtren al host. Trampa típica al delegar mount ns.
+pub unsafe fn make_root_private() {
+    libc::mount(
+        std::ptr::null(),
+        b"/\0".as_ptr() as *const _,
+        std::ptr::null(),
+        libc::MS_PRIVATE | libc::MS_REC,
+        std::ptr::null(),
+    );
+}
diff --git a/crates/shared/ente-incarnate/src/env.rs b/crates/shared/ente-incarnate/src/env.rs
new file mode 100644
index 0000000..820ae57
--- /dev/null
+++ b/crates/shared/ente-incarnate/src/env.rs
@@ -0,0 +1,95 @@
+//! Construcción del entorno del hijo. Sin globals — toma EnvSpec por valor.
+
+use brahman_card::Card;
+use std::path::PathBuf;
+
+/// Var env para el path del bus interno (cuando aplica). Mismo nombre que
+/// usa ente-bus para que clientes existentes (`BusClient::from_env`) sigan
+/// funcionando sin cambios.
+pub const ENV_BUS_SOCK: &str = "ENTE_BUS_SOCK";
+
+/// Var env para el ULID de la Card encarnada.
+pub const ENV_ENTE_ID: &str = "ENTE_ID";
+
+#[derive(Debug, Clone, Default)]
+pub struct EnvSpec {
+    /// Si `Some`, se inyecta como ENTE_BUS_SOCK.
+    pub bus_sock: Option<PathBuf>,
+    /// Si `Some`, se inyecta como NOTIFY_SOCKET (legacy sd_notify).
+    pub notify_socket: Option<PathBuf>,
+    /// Vars adicionales que el caller quiere forzar.
+    pub extra: Vec<(String, String)>,
+}
+
+/// Hereda env del padre, aplica el envp explícito de la Card, y al final
+/// inyecta las vars del fractal según `EnvSpec`.
+pub fn build_env(card: &Card, base_envp: &[(String, String)], spec: &EnvSpec) -> Vec<(String, String)> {
+    let mut env: Vec<(String, String)> = std::env::vars().collect();
+
+    for (k, v) in base_envp {
+        env.retain(|(ek, _)| ek != k);
+        env.push((k.clone(), v.clone()));
+    }
+
+    if let Some(p) = &spec.bus_sock {
+        env.retain(|(k, _)| k != ENV_BUS_SOCK);
+        env.push((ENV_BUS_SOCK.into(), p.to_string_lossy().into_owned()));
+    }
+
+    env.retain(|(k, _)| k != ENV_ENTE_ID);
+    env.push((ENV_ENTE_ID.into(), card.id.to_string()));
+
+    if let Some(p) = &spec.notify_socket {
+        env.retain(|(k, _)| k != "NOTIFY_SOCKET");
+        env.push(("NOTIFY_SOCKET".into(), p.to_string_lossy().into_owned()));
+    }
+
+    for (k, v) in &spec.extra {
+        env.retain(|(ek, _)| ek != k);
+        env.push((k.clone(), v.clone()));
+    }
+
+    env
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use brahman_card::Card;
+
+    #[test]
+    fn env_id_and_bus_injected() {
+        let card = Card::new("test");
+        let spec = EnvSpec {
+            bus_sock: Some(PathBuf::from("/tmp/bus.sock")),
+            notify_socket: None,
+            extra: vec![],
+        };
+        let env = build_env(&card, &[], &spec);
+        assert!(env.iter().any(|(k, v)| k == ENV_ENTE_ID && v == &card.id.to_string()));
+        assert!(env.iter().any(|(k, v)| k == ENV_BUS_SOCK && v == "/tmp/bus.sock"));
+    }
+
+    #[test]
+    fn extra_overrides_inherited() {
+        let card = Card::new("test");
+        let spec = EnvSpec {
+            bus_sock: None,
+            notify_socket: None,
+            extra: vec![("PATH".into(), "/sandbox/bin".into())],
+        };
+        let env = build_env(&card, &[], &spec);
+        let path_count = env.iter().filter(|(k, _)| k == "PATH").count();
+        assert_eq!(path_count, 1);
+        assert_eq!(env.iter().find(|(k, _)| k == "PATH").unwrap().1, "/sandbox/bin");
+    }
+
+    #[test]
+    fn notify_socket_only_when_set() {
+        let card = Card::new("test");
+        let spec = EnvSpec::default();
+        let env = build_env(&card, &[], &spec);
+        assert!(!env.iter().any(|(k, _)| k == "NOTIFY_SOCKET"
+            && std::env::var("NOTIFY_SOCKET").is_err()));
+    }
+}
diff --git a/crates/shared/ente-incarnate/src/error.rs b/crates/shared/ente-incarnate/src/error.rs
new file mode 100644
index 0000000..bd91a39
--- /dev/null
+++ b/crates/shared/ente-incarnate/src/error.rs
@@ -0,0 +1,44 @@
+use std::path::PathBuf;
+
+#[derive(Debug, thiserror::Error)]
+pub enum IncarnateError {
+    #[error("namespace `{ns}` requires CAP_SYS_ADMIN or CLONE_NEWUSER (neither available)")]
+    NamespaceCapMissing { ns: &'static str },
+
+    #[error("user namespaces blocked by sysctl kernel.unprivileged_userns_clone=0")]
+    UserNsDisabledBySysctl,
+
+    #[error("user namespaces restricted by LSM (apparmor/selinux)")]
+    UserNsRestrictedByLsm,
+
+    #[error("cgroup path `{path}` is not writable (delegation missing?)")]
+    CgroupNotWritable { path: PathBuf },
+
+    #[error("payload is not executable in this incarnation path (Wasm/Virtual not supported here)")]
+    NonExecutablePayload,
+
+    #[error("clone(2) failed: {0}")]
+    Clone(#[source] nix::errno::Errno),
+
+    #[error("pipe2(2) failed: {0}")]
+    Pipe(#[source] nix::errno::Errno),
+
+    #[error("post-clone setup: {0}")]
+    PostClone(#[source] anyhow::Error),
+
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
+
+    #[error("invalid argv: contains NUL byte")]
+    InvalidArgv,
+}
+
+/// Cuando `strict_caps = false`, errores no-fatales se reportan como
+/// `Degradation` y la encarnación continúa con menos aislamiento del pedido.
+#[derive(Debug, Clone)]
+pub enum Degradation {
+    NamespaceSkipped { ns: &'static str },
+    CgroupSkipped { path: PathBuf, reason: String },
+    CpuAffinitySkipped { reason: String },
+    UidMapFailed { reason: String },
+}
diff --git a/crates/shared/ente-incarnate/src/lib.rs b/crates/shared/ente-incarnate/src/lib.rs
new file mode 100644
index 0000000..4f4b66b
--- /dev/null
+++ b/crates/shared/ente-incarnate/src/lib.rs
@@ -0,0 +1,365 @@
+//! `ente-incarnate` — rutina extraída del Init para encarnar Cards en
+//! procesos aislados (clone(2) + namespaces + cgroup + rlimits + cpu affinity).
+//!
+//! El núcleo histórico vivía en `ente-soma` con globals dependientes de PID 1.
+//! Este crate elimina esos globals: se construye un [`Incarnator`] por
+//! supervisor (Init, shipote, etc.), cada uno con su propio bus socket y su
+//! propia política de capacidades.
+//!
+//! ## Limitaciones que NO desaparecen al extraer
+//!
+//! 1. `mount/pid/net/uts/ipc/cgroup` namespaces requieren `CAP_SYS_ADMIN`
+//!    o estar combinados con `CLONE_NEWUSER` en el mismo `clone(2)`.
+//! 2. `user` namespace puede estar bloqueado por
+//!    `kernel.unprivileged_userns_clone=0` o por LSM (apparmor/selinux).
+//! 3. cgroups v2 requieren delegación (sistemas modernos: systemd
+//!    `Delegate=yes`). Sin delegación, escribir en `/sys/fs/cgroup` falla.
+//! 4. El primer proceso de un PID namespace es PID 1 *de ese ns*; si muere
+//!    el kernel mata el namespace entero.
+//!
+//! [`CapabilitySet::detect`] reporta lo que está disponible runtime;
+//! [`Incarnator::dry_run`] valida un [`Card`] antes de ejecutar.
+
+#![doc(html_no_source)]
+
+pub mod caps;
+pub mod cgroup;
+pub mod child;
+pub mod env;
+pub mod error;
+pub mod namespaced;
+pub mod plain;
+
+pub use brahman_card::Card;
+pub use caps::{CapabilitySet, CgroupStatus, NsKind, UserNsStatus};
+pub use env::{EnvSpec, ENV_BUS_SOCK, ENV_ENTE_ID};
+pub use error::{Degradation, IncarnateError};
+
+use std::os::fd::RawFd;
+
+/// Redirección declarativa de stdio del hijo. Cada `Some(fd)` se `dup2`-ea
+/// como stdin/stdout/stderr en el hijo.
+///
+/// **Contrato de ownership**: el caller transfiere ownership de los FDs al
+/// `Incarnator` (igual que pasaría a `Command::stdio(Stdio::from_raw_fd)`).
+/// `Incarnator` se encarga de cerrarlos en el padre tras `incarnate` (path
+/// namespaced) o de dejar que `std::process::Command` los absorba (path
+/// plain). **No los cierres en el caller** — habría doble-close.
+///
+/// Útil para conectar pipes entre procesos del pipeline de shipote sin
+/// romper la regla async-signal-safe del callback de clone(2).
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ChildStdio {
+    pub stdin_fd: Option<RawFd>,
+    pub stdout_fd: Option<RawFd>,
+    pub stderr_fd: Option<RawFd>,
+}
+
+impl ChildStdio {
+    pub fn is_some(&self) -> bool {
+        self.stdin_fd.is_some() || self.stdout_fd.is_some() || self.stderr_fd.is_some()
+    }
+}
+
+use nix::unistd::Pid;
+use std::path::PathBuf;
+
+#[derive(Debug, Clone, Default)]
+pub struct IncarnatorConfig {
+    /// Path del Unix socket del bus interno (se inyecta como `ENTE_BUS_SOCK`).
+    /// `None` = no inyectar.
+    pub bus_sock: Option<PathBuf>,
+
+    /// Inyectar `NOTIFY_SOCKET` (legacy sd_notify). Default `None`.
+    /// `ente-zero` lo pasa = `Some("/run/systemd/notify")`.
+    pub notify_socket: Option<PathBuf>,
+
+    /// Vars adicionales que el caller fuerza en cada hijo.
+    pub extra_env: Vec<(String, String)>,
+
+    /// Si `true`, falta de capacidades aborta `incarnate()` con error.
+    /// Si `false`, se reportan como `Degradation` y la encarnación continúa
+    /// con menos aislamiento (semántica histórica del Init).
+    pub strict_caps: bool,
+}
+
+pub struct Incarnator {
+    cfg: IncarnatorConfig,
+    caps: CapabilitySet,
+}
+
+#[derive(Debug, Clone)]
+pub struct IncarnateOutcome {
+    pub pid: Pid,
+    pub degradations: Vec<Degradation>,
+}
+
+#[derive(Debug, Default, Clone)]
+pub struct ValidationReport {
+    pub will_work: bool,
+    pub blocking: Vec<String>,
+    pub warnings: Vec<String>,
+}
+
+impl Incarnator {
+    pub fn new(cfg: IncarnatorConfig) -> Self {
+        Self {
+            caps: CapabilitySet::detect(),
+            cfg,
+        }
+    }
+
+    /// Constructor para testing/inyección de capacidades pre-calculadas.
+    pub fn with_caps(cfg: IncarnatorConfig, caps: CapabilitySet) -> Self {
+        Self { cfg, caps }
+    }
+
+    pub fn capabilities(&self) -> &CapabilitySet {
+        &self.caps
+    }
+
+    pub fn config(&self) -> &IncarnatorConfig {
+        &self.cfg
+    }
+
+    /// Valida una Card sin ejecutar nada. Útil para que el caller (shipote,
+    /// admin, tests) sepa de antemano si va a poder encarnar tal cual o si
+    /// va a tener que aflojar el SomaSpec.
+    pub fn dry_run(&self, card: &Card) -> ValidationReport {
+        let mut r = ValidationReport {
+            will_work: true,
+            ..Default::default()
+        };
+        let ns = &card.soma.namespaces;
+
+        // Si user_ns está pedido, evaluar su disponibilidad.
+        if ns.user {
+            match self.caps.user_ns {
+                UserNsStatus::DisabledBySysctl => {
+                    r.blocking.push(
+                        "user namespace requested but kernel.unprivileged_userns_clone=0".into(),
+                    );
+                    r.will_work = false;
+                }
+                UserNsStatus::RestrictedByLsm => {
+                    r.blocking.push(
+                        "user namespace restricted by LSM (apparmor/selinux)".into(),
+                    );
+                    r.will_work = false;
+                }
+                _ => {}
+            }
+        }
+
+        // El resto de namespaces necesitan CAP_SYS_ADMIN o user ns.
+        let needs_priv = [
+            (ns.mount, NsKind::Mount),
+            (ns.pid, NsKind::Pid),
+            (ns.net, NsKind::Net),
+            (ns.uts, NsKind::Uts),
+            (ns.ipc, NsKind::Ipc),
+            (ns.cgroup, NsKind::Cgroup),
+        ];
+        for (wanted, kind) in needs_priv {
+            if wanted && !self.caps.can_create_ns(kind) {
+                r.blocking.push(format!(
+                    "{} namespace requires CAP_SYS_ADMIN or user ns (neither available)",
+                    kind.name()
+                ));
+                r.will_work = false;
+            }
+        }
+
+        // Cgroup: si el card pide path, chequear que tengamos delegación.
+        if !card.soma.cgroup.path.is_empty() && !self.caps.cgroup_delegated {
+            r.warnings.push(format!(
+                "cgroup `{}` requested but our cgroup is not writable (delegation missing)",
+                card.soma.cgroup.path
+            ));
+        }
+
+        // Payload ejecutable.
+        use brahman_card::Payload;
+        if !matches!(card.payload, Payload::Native { .. } | Payload::Legacy { .. }) {
+            r.blocking
+                .push("payload is not Native/Legacy (use ente-wasm for Wasm)".into());
+            r.will_work = false;
+        }
+
+        r
+    }
+
+    /// Encarna la Card. Si `strict_caps`, valida primero y aborta ante
+    /// blocking. Si no, ejecuta y deja que las degradaciones se acumulen.
+    pub fn incarnate(&self, card: &Card) -> Result<IncarnateOutcome, IncarnateError> {
+        self.incarnate_with(card, ChildStdio::default())
+    }
+
+    /// Variante con redirección de stdio declarativa. Útil para conectar
+    /// pipes entre procesos (caso: pipeline aislado).
+    pub fn incarnate_with(
+        &self,
+        card: &Card,
+        stdio: ChildStdio,
+    ) -> Result<IncarnateOutcome, IncarnateError> {
+        if self.cfg.strict_caps {
+            let v = self.dry_run(card);
+            if !v.will_work {
+                // Mapeamos el primer blocking a IncarnateError tipado.
+                if let Some(first) = v.blocking.first() {
+                    if first.contains("unprivileged_userns_clone") {
+                        return Err(IncarnateError::UserNsDisabledBySysctl);
+                    }
+                    if first.contains("LSM") {
+                        return Err(IncarnateError::UserNsRestrictedByLsm);
+                    }
+                    if let Some(ns) = which_ns_blocking(first) {
+                        return Err(IncarnateError::NamespaceCapMissing { ns });
+                    }
+                    if first.contains("payload") {
+                        return Err(IncarnateError::NonExecutablePayload);
+                    }
+                }
+            }
+        }
+
+        let env_spec = EnvSpec {
+            bus_sock: self.cfg.bus_sock.clone(),
+            notify_socket: self.cfg.notify_socket.clone(),
+            extra: self.cfg.extra_env.clone(),
+        };
+
+        let mut degradations = Vec::new();
+        let pid = if namespaced::needs_namespacing(&card.soma.namespaces) {
+            namespaced::incarnate_namespaced(card, &env_spec, &stdio, &mut degradations)?
+        } else {
+            plain::incarnate_plain(card, &env_spec, &stdio)?
+        };
+        Ok(IncarnateOutcome { pid, degradations })
+    }
+}
+
+fn which_ns_blocking(msg: &str) -> Option<&'static str> {
+    for n in ["mount", "pid", "net", "uts", "ipc", "user", "cgroup"] {
+        if msg.starts_with(n) {
+            return Some(match n {
+                "mount" => "mount",
+                "pid" => "pid",
+                "net" => "net",
+                "uts" => "uts",
+                "ipc" => "ipc",
+                "user" => "user",
+                "cgroup" => "cgroup",
+                _ => unreachable!(),
+            });
+        }
+    }
+    None
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use brahman_card::{Card, NamespaceSet, Payload};
+
+    fn make_card(payload: Payload, ns: NamespaceSet) -> Card {
+        let mut c = Card::new("test");
+        c.payload = payload;
+        c.soma.namespaces = ns;
+        c
+    }
+
+    #[test]
+    fn dry_run_native_no_ns_works() {
+        let inc = Incarnator::new(IncarnatorConfig::default());
+        let card = make_card(
+            Payload::Native {
+                exec: "/bin/true".into(),
+                argv: vec![],
+                envp: vec![],
+            },
+            NamespaceSet::default(),
+        );
+        let r = inc.dry_run(&card);
+        assert!(r.will_work, "{:?}", r);
+    }
+
+    #[test]
+    fn dry_run_wasm_payload_blocks() {
+        let inc = Incarnator::new(IncarnatorConfig::default());
+        let card = make_card(
+            Payload::Wasm {
+                module_sha256: [0u8; 32],
+                entry: "main".into(),
+            },
+            NamespaceSet::default(),
+        );
+        let r = inc.dry_run(&card);
+        assert!(!r.will_work);
+        assert!(r.blocking.iter().any(|m| m.contains("payload")));
+    }
+
+    /// Smoke: redirección stdout via ChildStdio en path plain.
+    /// Lanza /bin/echo con stdout conectado a un pipe que leemos.
+    #[test]
+    fn incarnate_with_stdout_redirection_captures_output() {
+        use nix::fcntl::OFlag;
+        use nix::unistd::{pipe2, read};
+        use std::os::fd::{AsRawFd, IntoRawFd};
+
+        let inc = Incarnator::new(IncarnatorConfig::default());
+        let card = make_card(
+            Payload::Native {
+                exec: "/bin/echo".into(),
+                argv: vec!["shipote-stdio".into()],
+                envp: vec![],
+            },
+            NamespaceSet::default(),
+        );
+
+        let (r, w) = pipe2(OFlag::empty()).expect("pipe");
+        let w_raw = w.into_raw_fd();
+        let r_raw = r.as_raw_fd();
+
+        let stdio = ChildStdio {
+            stdin_fd: None,
+            stdout_fd: Some(w_raw),
+            stderr_fd: None,
+        };
+        let out = inc.incarnate_with(&card, stdio).expect("incarnate");
+
+        // Cerramos nuestro extremo de write (el hijo tiene su dup2).
+        // Plain path: Command toma ownership y cierra al spawn.
+        // Namespaced path: el padre todavía tiene una copia... pero en plain
+        // no aplica. Para este test usamos plain (NamespaceSet vacío).
+
+        // Cosechamos para no zombi.
+        let _ = nix::sys::wait::waitpid(out.pid, None);
+
+        // Leemos la salida.
+        let mut buf = [0u8; 64];
+        let n = read(r_raw, &mut buf).expect("read");
+        assert!(n > 0);
+        let s = std::str::from_utf8(&buf[..n]).unwrap();
+        assert!(s.contains("shipote-stdio"), "got: {s:?}");
+        // r se cierra al drop del OwnedFd.
+    }
+
+    /// Smoke: encarnar /bin/true sin ns. No requiere root.
+    #[test]
+    fn incarnate_plain_true_succeeds() {
+        let inc = Incarnator::new(IncarnatorConfig::default());
+        let card = make_card(
+            Payload::Native {
+                exec: "/bin/true".into(),
+                argv: vec![],
+                envp: vec![],
+            },
+            NamespaceSet::default(),
+        );
+        let out = inc.incarnate(&card).expect("plain incarnation");
+        assert!(out.pid.as_raw() > 0);
+        // Cosechamos para no dejar zombi.
+        let _ = nix::sys::wait::waitpid(out.pid, None);
+    }
+}
diff --git a/crates/shared/ente-incarnate/src/namespaced.rs b/crates/shared/ente-incarnate/src/namespaced.rs
new file mode 100644
index 0000000..8a22609
--- /dev/null
+++ b/crates/shared/ente-incarnate/src/namespaced.rs
@@ -0,0 +1,302 @@
+//! Path namespaced: clone(2) + sync pipe + setup post-clone en padre + finalize en hijo.
+//!
+//! ## Protocolo padre↔hijo
+//!
+//! ```text
+//!   parent               child
+//!     |                    |
+//!     |--- clone() ------->|   (child empieza dentro de los nuevos NS)
+//!     |                    |
+//!     |                    |---- read(sync_r, 1) ----  (bloquea)
+//!     |                    |
+//!     |  write uid_map     |
+//!     |  write gid_map     |
+//!     |  cgroup move       |
+//!     |  cpu affinity      |
+//!     |                    |
+//!     |--- write(sync_w) ->|
+//!     |                    |---- setrlimit
+//!     |                    |---- mount(/, MS_PRIVATE | MS_REC)
+//!     |                    |---- execve()
+//! ```
+
+use crate::child::{apply_rlimits, make_root_private};
+use crate::cgroup::{ensure_cgroup, move_to_cgroup};
+use crate::env::{build_env, EnvSpec};
+use crate::error::{Degradation, IncarnateError};
+use crate::ChildStdio;
+use brahman_card::{Card, NamespaceSet, Payload};
+use nix::fcntl::OFlag;
+use nix::sched::CloneFlags;
+use nix::unistd::{pipe2, Pid};
+use std::ffi::CString;
+use std::os::fd::{IntoRawFd, RawFd};
+use tracing::{info, warn};
+
+pub fn needs_namespacing(ns: &NamespaceSet) -> bool {
+    ns.mount || ns.pid || ns.net || ns.uts || ns.ipc || ns.user || ns.cgroup
+}
+
+pub fn build_clone_flags(ns: &NamespaceSet) -> CloneFlags {
+    let mut f = CloneFlags::empty();
+    if ns.mount  { f |= CloneFlags::CLONE_NEWNS; }
+    if ns.pid    { f |= CloneFlags::CLONE_NEWPID; }
+    if ns.net    { f |= CloneFlags::CLONE_NEWNET; }
+    if ns.uts    { f |= CloneFlags::CLONE_NEWUTS; }
+    if ns.ipc    { f |= CloneFlags::CLONE_NEWIPC; }
+    if ns.user   { f |= CloneFlags::CLONE_NEWUSER; }
+    if ns.cgroup { f |= CloneFlags::CLONE_NEWCGROUP; }
+    f
+}
+
+pub fn incarnate_namespaced(
+    card: &Card,
+    env_spec: &EnvSpec,
+    stdio: &ChildStdio,
+    degradations: &mut Vec<Degradation>,
+) -> Result<Pid, IncarnateError> {
+    let flags = build_clone_flags(&card.soma.namespaces);
+    info!(label = %card.label, ?flags, "namespaced incarnation");
+
+    let (exec, argv, base_envp) = match &card.payload {
+        Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()),
+        Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()),
+        _ => return Err(IncarnateError::NonExecutablePayload),
+    };
+
+    // Pipe O_CLOEXEC: el read del lado hijo es lo que hace race-free el setup.
+    // O_CLOEXEC garantiza cierre automático en execve.
+    let (sync_r, sync_w) = pipe2(OFlag::O_CLOEXEC).map_err(IncarnateError::Pipe)?;
+    let sync_r_raw: RawFd = sync_r.into_raw_fd();
+    let sync_w_raw: RawFd = sync_w.into_raw_fd();
+
+    let exec_c = CString::new(exec.clone()).map_err(|_| IncarnateError::InvalidArgv)?;
+    let argv_c: Vec<CString> = std::iter::once(exec_c.clone())
+        .chain(argv.iter().filter_map(|s| CString::new(s.as_str()).ok()))
+        .collect();
+    let argv_ptrs: Vec<*const libc::c_char> = argv_c
+        .iter()
+        .map(|c| c.as_ptr())
+        .chain(std::iter::once(std::ptr::null()))
+        .collect();
+
+    let env_pairs = build_env(card, &base_envp, env_spec);
+    let envp_c: Vec<CString> = env_pairs
+        .iter()
+        .filter_map(|(k, v)| CString::new(format!("{k}={v}")).ok())
+        .collect();
+    let envp_ptrs: Vec<*const libc::c_char> = envp_c
+        .iter()
+        .map(|c| c.as_ptr())
+        .chain(std::iter::once(std::ptr::null()))
+        .collect();
+
+    let rlimits = card.soma.rlimits.clone();
+    let mount_ns_enabled = card.soma.namespaces.mount;
+    let stdin_fd = stdio.stdin_fd;
+    let stdout_fd = stdio.stdout_fd;
+    let stderr_fd = stdio.stderr_fd;
+
+    // SAFETY: la clausura corre en stack nuevo dentro de un proceso recién
+    // clonado, COW del padre. Sólo syscalls async-signal-safe; sin allocator,
+    // sin Drop con efectos.
+    let cb = Box::new(move || -> isize {
+        unsafe { libc::close(sync_w_raw); }
+
+        let mut byte = [0u8; 1];
+        let n = unsafe { libc::read(sync_r_raw, byte.as_mut_ptr() as *mut _, 1) };
+        if n != 1 {
+            unsafe { libc::_exit(101); }
+        }
+        unsafe { libc::close(sync_r_raw); }
+
+        unsafe { apply_rlimits(&rlimits); }
+
+        if mount_ns_enabled {
+            unsafe { make_root_private(); }
+        }
+
+        // dup2 declarativo: caller pasó fds que queremos como stdin/out/err.
+        // dup2 es async-signal-safe (POSIX) y cierra el fd target si estaba
+        // abierto. El fd source NO se cierra automáticamente — el padre
+        // tiene su propia copia.
+        if let Some(fd) = stdin_fd {
+            unsafe {
+                if libc::dup2(fd, 0) < 0 {
+                    libc::_exit(103);
+                }
+            }
+        }
+        if let Some(fd) = stdout_fd {
+            unsafe {
+                if libc::dup2(fd, 1) < 0 {
+                    libc::_exit(104);
+                }
+            }
+        }
+        if let Some(fd) = stderr_fd {
+            unsafe {
+                if libc::dup2(fd, 2) < 0 {
+                    libc::_exit(105);
+                }
+            }
+        }
+
+        unsafe {
+            libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr());
+            libc::_exit(102);
+        }
+    });
+
+    let mut stack = vec![0u8; 1024 * 1024];
+
+    #[allow(deprecated)]
+    let pid = unsafe { nix::sched::clone(cb, &mut stack, flags, Some(libc::SIGCHLD)) }
+        .map_err(|e| {
+            unsafe {
+                libc::close(sync_r_raw);
+                libc::close(sync_w_raw);
+            }
+            IncarnateError::Clone(e)
+        })?;
+
+    // Padre: cerrar el extremo de lectura.
+    unsafe { libc::close(sync_r_raw); }
+
+    // Setup post-clone. Errores aquí los registramos como degradations y
+    // continuamos (la decisión strict_caps la toma el wrapper).
+    if let Err(e) = configure_child(pid, card, degradations) {
+        warn!(?e, ?pid, "configure_child errores");
+    }
+
+    // Despertar al hijo.
+    let signal_byte = [b'x'];
+    let written = unsafe { libc::write(sync_w_raw, signal_byte.as_ptr() as *const _, 1) };
+    unsafe { libc::close(sync_w_raw); }
+    if written != 1 {
+        warn!(?pid, "write sync pipe devolvió {}", written);
+    }
+
+    // El hijo ya dup2-eó los fds del ChildStdio. La copia del padre no
+    // sirve más y la cerramos para que el otro extremo del pipe reciba
+    // EOF cuando corresponda.
+    if let Some(fd) = stdio.stdin_fd {
+        unsafe { libc::close(fd); }
+    }
+    if let Some(fd) = stdio.stdout_fd {
+        unsafe { libc::close(fd); }
+    }
+    if let Some(fd) = stdio.stderr_fd {
+        unsafe { libc::close(fd); }
+    }
+
+    Ok(pid)
+}
+
+/// Setup que requiere capacidades del padre: uid_map, gid_map, cgroup move.
+/// Estos archivos en `/proc/<pid>/*` tienen reglas de propiedad que sólo el
+/// padre puede satisfacer mientras el hijo está suspendido en el sync pipe.
+fn configure_child(
+    pid: Pid,
+    card: &Card,
+    degradations: &mut Vec<Degradation>,
+) -> Result<(), IncarnateError> {
+    if card.soma.namespaces.user {
+        // Desde kernel 3.19 hay que escribir "deny" a setgroups antes de
+        // poder escribir gid_map sin CAP_SETGID. Ignorar errores aquí: en
+        // kernels antiguos el archivo no existe.
+        let _ = std::fs::write(format!("/proc/{}/setgroups", pid.as_raw()), "deny");
+
+        let uid = nix::unistd::getuid().as_raw();
+        let gid = nix::unistd::getgid().as_raw();
+        if let Err(e) = std::fs::write(
+            format!("/proc/{}/uid_map", pid.as_raw()),
+            format!("0 {uid} 1"),
+        ) {
+            degradations.push(Degradation::UidMapFailed {
+                reason: format!("uid_map: {e}"),
+            });
+        }
+        if let Err(e) = std::fs::write(
+            format!("/proc/{}/gid_map", pid.as_raw()),
+            format!("0 {gid} 1"),
+        ) {
+            degradations.push(Degradation::UidMapFailed {
+                reason: format!("gid_map: {e}"),
+            });
+        }
+    }
+
+    if !card.soma.cgroup.path.is_empty() {
+        match ensure_cgroup(&card.soma.cgroup) {
+            Ok(abs) => {
+                if let Err(e) = move_to_cgroup(&abs, pid) {
+                    degradations.push(Degradation::CgroupSkipped {
+                        path: abs,
+                        reason: format!("{e}"),
+                    });
+                }
+            }
+            Err(e) => degradations.push(Degradation::CgroupSkipped {
+                path: std::path::PathBuf::from(&card.soma.cgroup.path),
+                reason: format!("{e}"),
+            }),
+        }
+    }
+
+    if let Some(cpus) = &card.soma.cpu_affinity {
+        if let Err(e) = set_cpu_affinity(pid, cpus) {
+            degradations.push(Degradation::CpuAffinitySkipped {
+                reason: format!("{e}"),
+            });
+        }
+    }
+
+    Ok(())
+}
+
+fn set_cpu_affinity(pid: Pid, cpus: &[u32]) -> Result<(), std::io::Error> {
+    let mut set: libc::cpu_set_t = unsafe { std::mem::zeroed() };
+    unsafe { libc::CPU_ZERO(&mut set); }
+    for &c in cpus {
+        unsafe { libc::CPU_SET(c as usize, &mut set); }
+    }
+    let r = unsafe {
+        libc::sched_setaffinity(pid.as_raw(), std::mem::size_of::<libc::cpu_set_t>(), &set)
+    };
+    if r != 0 {
+        Err(std::io::Error::last_os_error())
+    } else {
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use brahman_card::NamespaceSet;
+
+    #[test]
+    fn empty_ns_does_not_need_namespacing() {
+        let ns = NamespaceSet::default();
+        assert!(!needs_namespacing(&ns));
+    }
+
+    #[test]
+    fn any_ns_triggers_namespacing() {
+        let mut ns = NamespaceSet::default();
+        ns.user = true;
+        assert!(needs_namespacing(&ns));
+    }
+
+    #[test]
+    fn flags_match_namespace_bools() {
+        let mut ns = NamespaceSet::default();
+        ns.user = true;
+        ns.pid = true;
+        let f = build_clone_flags(&ns);
+        assert!(f.contains(CloneFlags::CLONE_NEWUSER));
+        assert!(f.contains(CloneFlags::CLONE_NEWPID));
+        assert!(!f.contains(CloneFlags::CLONE_NEWNET));
+    }
+}
diff --git a/crates/shared/ente-incarnate/src/plain.rs b/crates/shared/ente-incarnate/src/plain.rs
new file mode 100644
index 0000000..63ab835
--- /dev/null
+++ b/crates/shared/ente-incarnate/src/plain.rs
@@ -0,0 +1,41 @@
+//! Path simple: spawn directo, sin namespacing.
+
+use crate::env::{build_env, EnvSpec};
+use crate::error::IncarnateError;
+use crate::ChildStdio;
+use brahman_card::{Card, Payload};
+use nix::unistd::Pid;
+use std::os::fd::FromRawFd;
+use std::process::{Command, Stdio};
+
+pub fn incarnate_plain(
+    card: &Card,
+    env_spec: &EnvSpec,
+    stdio: &ChildStdio,
+) -> Result<Pid, IncarnateError> {
+    let (exec, argv, base_envp) = match &card.payload {
+        Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()),
+        Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()),
+        _ => return Err(IncarnateError::NonExecutablePayload),
+    };
+    let env = build_env(card, &base_envp, env_spec);
+    let mut cmd = Command::new(&exec);
+    cmd.args(&argv);
+    cmd.env_clear();
+    for (k, v) in &env {
+        cmd.env(k, v);
+    }
+    if let Some(fd) = stdio.stdin_fd {
+        // SAFETY: el caller garantiza que `fd` está abierto y le
+        // transfiere ownership al child. `Command` lo cierra tras spawn.
+        cmd.stdin(unsafe { Stdio::from_raw_fd(fd) });
+    }
+    if let Some(fd) = stdio.stdout_fd {
+        cmd.stdout(unsafe { Stdio::from_raw_fd(fd) });
+    }
+    if let Some(fd) = stdio.stderr_fd {
+        cmd.stderr(unsafe { Stdio::from_raw_fd(fd) });
+    }
+    let child = cmd.spawn()?;
+    Ok(Pid::from_raw(child.id() as i32))
+}