use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::policy::{ReceiverKind, ReceiverState};
use super::{is_lane_id, SenderKind};
use crate::live::{
background_report, children_report, probe_pid, registry_row_for, tail_shape, BackgroundLens,
BgKind, BgState, PidLiveness,
};
const SESSION_ENV: &str = "CLAUDE_CODE_SESSION_ID";
const DEFAULT_EXTERNAL_LABEL: &str = "unknown";
#[derive(Debug, Clone)]
pub(crate) struct Caller {
pub(crate) kind: SenderKind,
pub(crate) session: Option<String>,
pub(crate) lane: Option<String>,
pub(crate) label: Option<String>,
pub(crate) lane_exact: bool,
}
pub(crate) fn classify(from: Option<&str>) -> Result<Caller> {
classify_with(
from,
std::env::var(SESSION_ENV)
.ok()
.filter(|v| !v.trim().is_empty()),
)
}
pub(crate) fn classify_with(from: Option<&str>, session: Option<String>) -> Result<Caller> {
match session {
Some(session) => classify_lane(from, session),
None => classify_external(from),
}
}
fn classify_lane(from: Option<&str>, session: String) -> Result<Caller> {
let (lane, exact) = match from {
None => (session.clone(), false),
Some(raw) => {
let Some(id) = raw.strip_prefix('@') else {
bail!(
"--from `{raw}`: inside Claude Code the sender is a LANE, so --from takes \
the `@<lane>` form (`@main`, or the `a...` id `csift agents` prints). A \
bare label is the external-caller form and would misattribute a real lane."
);
};
if id == "main" {
(session.clone(), true)
} else if is_lane_id(id) {
(id.to_string(), true)
} else {
bail!(
"--from `@{id}` is not a lane id: a lane is a top-level session uuid, a bare \
`a<16 hex>` agent id, or a teammate id `a<Name>-<16 hex>` - exactly what \
`csift agents` prints. `@main` names the calling top-level session."
);
}
}
};
Ok(Caller {
kind: SenderKind::Lane,
session: Some(session),
lane: Some(lane),
label: None,
lane_exact: exact,
})
}
fn classify_external(from: Option<&str>) -> Result<Caller> {
let label = match from {
None => DEFAULT_EXTERNAL_LABEL.to_string(),
Some(raw) if raw.starts_with('@') => bail!(
"--from `{raw}`: outside Claude Code there is no lane to claim, so --from is a free \
LABEL for the receipt (`--from ci-runner`), never an `@<lane>` id. csift cannot \
verify a lane claim from a process it did not spawn."
),
Some(raw) if raw.trim().is_empty() => DEFAULT_EXTERNAL_LABEL.to_string(),
Some(raw) => raw.to_string(),
};
Ok(Caller {
kind: SenderKind::External,
session: None,
lane: None,
label: Some(label),
lane_exact: true,
})
}
pub(crate) const LANE_ASSUMED_NOTE: &str =
"csift: lane unknown, sending as the top-level session; pass --from @<your lane id> for an \
exact sender (a subagent's own id is withheld from its environment - `csift whoami \
@trap:<marker>` recovers it).";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GateVerdict {
pub(crate) name: &'static str,
pub(crate) verdict: String,
pub(crate) enabled: bool,
}
impl GateVerdict {
pub(crate) fn teams(scope: Option<&str>, teams_dirs: usize, teammate_lanes: usize) -> Self {
match scope {
Some(s) => GateVerdict {
name: "teams",
verdict: format!("enabled via settings env ({s})"),
enabled: true,
},
None => GateVerdict {
name: "teams",
verdict: format!(
"no settings-level enable; shell env and CLI flags are not observable -> \
unknown; use evidence: teams directories {teams_dirs}, teammate lanes \
{teammate_lanes}"
),
enabled: false,
},
}
}
pub(crate) fn harbor(socket_present: bool) -> Self {
GateVerdict {
name: "harbor",
verdict: if socket_present {
"registry messagingSocketPath present -> on and bound".to_string()
} else {
"registry messagingSocketPath absent -> unknown".to_string()
},
enabled: socket_present,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SettingsDisclosure {
pub(crate) sources: Vec<crate::path::settings::SourceReport>,
pub(crate) unobservable: Vec<&'static str>,
}
impl SettingsDisclosure {
pub(crate) fn of(m: &crate::path::settings::Merged) -> Self {
SettingsDisclosure {
sources: m.sources.clone(),
unobservable: m.unobservable.clone(),
}
}
pub(crate) fn read_scopes(&self) -> Vec<&'static str> {
self.scopes(true)
}
pub(crate) fn absent_scopes(&self) -> Vec<&'static str> {
let read = self.scopes(true);
self.scopes(false)
.into_iter()
.filter(|s| !read.contains(s))
.collect()
}
fn scopes(&self, read: bool) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for s in self.sources.iter().filter(|s| s.read == read) {
if !out.contains(&s.scope) {
out.push(s.scope);
}
}
out
}
pub(crate) fn notes(&self) -> Vec<String> {
self.sources
.iter()
.filter_map(|s| s.note.as_ref().map(|n| format!("{} - {n}", s.scope)))
.collect()
}
pub(crate) fn lines(&self) -> Vec<String> {
let mut out = vec![format!(
"read: {} · absent: {}",
join_or_none(&self.read_scopes()),
join_or_none(&self.absent_scopes())
)];
for note in self.notes() {
out.push(format!("note: {note}"));
}
out.push(format!("unobservable: {}", self.unobservable.join("; ")));
out
}
pub(crate) fn json(&self) -> serde_json::Value {
let sources: Vec<serde_json::Value> = self
.sources
.iter()
.map(|s| {
serde_json::json!({
"scope": s.scope,
"path": s.path.to_string_lossy(),
"read": s.read,
"note": s.note,
})
})
.collect();
serde_json::json!({"sources": sources, "unobservable": self.unobservable})
}
}
fn join_or_none(scopes: &[&'static str]) -> String {
if scopes.is_empty() {
"none".to_string()
} else {
scopes.join(", ")
}
}
pub(crate) fn teams_dirs() -> usize {
let Ok(home) = crate::path::claude_home() else {
return 0;
};
std::fs::read_dir(home.join("teams"))
.map(|rd| rd.flatten().filter(|e| e.path().is_dir()).count())
.unwrap_or(0)
}
#[derive(Debug, Clone)]
pub(crate) struct Receiver {
pub(crate) lane: String,
pub(crate) session: String,
pub(crate) session_path: PathBuf,
pub(crate) kind: ReceiverKind,
pub(crate) state: ReceiverState,
pub(crate) version: Option<String>,
pub(crate) cwd: Option<String>,
pub(crate) routing_id: Option<String>,
pub(crate) socket_present: bool,
pub(crate) headless: bool,
pub(crate) teammate_lanes: usize,
}
pub(crate) fn probe_receiver(path: &Path) -> Result<Receiver> {
let lane = crate::subagent::session_id_from_path(path);
let is_sub = crate::subagent::is_subagent_path(path);
let session_path = session_transcript_for(path);
let session = crate::subagent::session_id_from_path(&session_path);
let (version, cwd) = head_facts(path)?;
let (kind, routing_id, teammate_lanes) = classify_receiver(&session_path, &lane, is_sub)?;
let row = if is_sub {
None
} else {
registry_row_for(&session)?
};
let extras = if is_sub {
RegistryExtras::default()
} else {
registry_extras(&session)?
};
let state = receiver_state(path, &session_path, &lane, is_sub, row.as_ref())?;
Ok(Receiver {
lane,
session,
session_path,
kind,
state,
version,
cwd,
routing_id,
socket_present: extras.socket_present,
headless: extras.headless,
teammate_lanes,
})
}
pub(crate) fn session_transcript_for(path: &Path) -> PathBuf {
let mut dir = path.parent();
while let Some(d) = dir {
if d.file_name().and_then(|n| n.to_str()) == Some("subagents") {
if let Some(session_dir) = d.parent() {
return session_dir.with_extension("jsonl");
}
}
dir = d.parent();
}
path.to_path_buf()
}
pub(crate) fn parent_agents_of(
session_path: &Path,
first: &str,
second: &str,
) -> (Option<String>, Option<String>) {
let Ok(nodes) = crate::subagent::build_topology(session_path, false) else {
return (None, None);
};
let parent_of = |lane: &str| {
nodes
.iter()
.find(|n| n.agent_id == lane)
.and_then(|n| n.parent_agent_id.clone())
};
(parent_of(first), parent_of(second))
}
fn classify_receiver(
session_path: &Path,
lane: &str,
is_sub: bool,
) -> Result<(ReceiverKind, Option<String>, usize)> {
if !is_sub {
return Ok((ReceiverKind::TopLevel, None, 0));
}
let subs = crate::subagent::discover_subagents(session_path).unwrap_or_default();
let teammate_lanes = subs
.iter()
.filter(|s| s.kind == crate::subagent::SubagentKind::Teammate)
.count();
let Some(me) = subs.iter().find(|s| s.agent_id == lane) else {
return Ok((ReceiverKind::UnnamedSubagent, None, teammate_lanes));
};
let kind = match me.kind {
crate::subagent::SubagentKind::Teammate => ReceiverKind::Teammate,
crate::subagent::SubagentKind::Workflow => ReceiverKind::WorkflowLane,
crate::subagent::SubagentKind::BuiltinTask => ReceiverKind::UnnamedSubagent,
};
let routing =
crate::subagent::routing_id(me.name.as_deref(), me.team_name.as_deref()).filter(|_| {
matches!(kind, ReceiverKind::Teammate) });
Ok((kind, routing, teammate_lanes))
}
fn head_facts(path: &Path) -> Result<(Option<String>, Option<String>)> {
let mut version = None;
let mut cwd = None;
crate::parse::head_records(path, |rec| {
if version.is_none() {
version.clone_from(&rec.version);
}
if cwd.is_none() {
cwd.clone_from(&rec.cwd);
}
version.is_none() || cwd.is_none()
})
.with_context(|| format!("reading the head of {}", path.display()))?;
Ok((version, cwd))
}
fn receiver_state(
path: &Path,
session_path: &Path,
lane: &str,
is_sub: bool,
row: Option<&crate::live::RegistryRow>,
) -> Result<ReceiverState> {
if stopped_by_user(session_path, lane)? {
return Ok(ReceiverState::StoppedByUser);
}
let shape = tail_shape(path)?;
if shape.records_seen == 0 {
return Ok(ReceiverState::Unknown);
}
if shape.unreturned_use.is_some() {
return Ok(ReceiverState::Frozen);
}
if is_sub {
let clean = shape.last_stop_reason.as_deref() == Some("end_turn");
let children = children_report(path, &HashSet::new())
.map(|r| r.live_count)
.unwrap_or(0);
return Ok(if clean && children == 0 {
ReceiverState::Completed
} else {
ReceiverState::Running
});
}
let Some(row) = row else {
return Ok(ReceiverState::Unknown);
};
let Some(pid) = row.pid else {
return Ok(ReceiverState::Unknown);
};
Ok(
match probe_pid(pid, row.proc_start.as_deref(), row.pid_domain.as_deref()) {
PidLiveness::Alive { .. } => ReceiverState::Running,
PidLiveness::Dead | PidLiveness::Reused => ReceiverState::Dead,
PidLiveness::ForeignDomain(_) | PidLiveness::Unavailable => ReceiverState::Unknown,
},
)
}
fn stopped_by_user(session_path: &Path, lane: &str) -> Result<bool> {
if !session_path.is_file() {
return Ok(false);
}
let lens = BackgroundLens::from_args(None, &[])?;
let report = background_report(session_path, false, &lens)?;
Ok(report.tasks.iter().any(|t| {
t.kind == BgKind::Agent
&& t.id.as_deref() == Some(lane)
&& matches!(t.state, BgState::Killed | BgState::Stopped)
}))
}
#[derive(Debug, Clone, Default)]
struct RegistryExtras {
socket_present: bool,
headless: bool,
}
fn registry_extras(session_id: &str) -> Result<RegistryExtras> {
let dir = crate::path::claude_home()?.join("sessions");
if !dir.is_dir() {
return Ok(RegistryExtras::default());
}
for entry in std::fs::read_dir(&dir)?.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(raw) = std::fs::read_to_string(&p) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue;
};
if v.get("sessionId").and_then(serde_json::Value::as_str) != Some(session_id) {
continue;
}
let socket = v
.get("messagingSocketPath")
.and_then(serde_json::Value::as_str)
.is_some_and(|s| !s.trim().is_empty());
let headless = v.get("entrypoint").and_then(serde_json::Value::as_str) == Some("sdk-cli");
return Ok(RegistryExtras {
socket_present: socket,
headless,
});
}
Ok(RegistryExtras::default())
}