use super::*;
#[derive(Debug, Clone, Default)]
pub(crate) struct ReachSlots {
pub(crate) per_event: Vec<(String, Vec<u32>)>,
pub(crate) best_event: Option<String>,
pub(crate) best: usize,
pub(crate) async_rewake_on_stop: bool,
}
impl ReachSlots {
pub(crate) fn read(m: &Merged) -> Self {
let mut out = ReachSlots::default();
for e in STEER_EVENTS {
let slots = settings::deliver_slots(m, e);
if slots.len() > out.best {
out.best = slots.len();
out.best_event = Some(e.to_string());
}
if !slots.is_empty() {
out.per_event.push((e.to_string(), slots));
}
}
out.async_rewake_on_stop = settings::hooks_for_event(m, "Stop")
.iter()
.any(|h| h.async_rewake);
out
}
}
#[derive(Debug, Clone)]
pub(crate) struct TargetFacts {
pub(crate) kind: ReceiverKind,
pub(crate) state: ReceiverState,
pub(crate) version: Option<String>,
pub(crate) lane: String,
pub(crate) routing_id: Option<String>,
pub(crate) socket_present: bool,
pub(crate) headless: bool,
pub(crate) teammate_lanes: usize,
}
pub(crate) fn target_facts(lane: &LaneFacts) -> TargetFacts {
TargetFacts {
kind: lane.kind,
state: lane.state,
version: lane.version.clone(),
lane: lane.lane.clone(),
routing_id: lane.routing_id.clone(),
socket_present: lane.socket_present,
headless: lane.headless,
teammate_lanes: 0,
}
}
fn receiver_target(r: &Receiver) -> TargetFacts {
TargetFacts {
kind: r.kind,
state: r.state,
version: r.version.clone(),
lane: r.lane.clone(),
routing_id: r.routing_id.clone(),
socket_present: r.socket_present,
headless: r.headless,
teammate_lanes: r.teammate_lanes,
}
}
pub(crate) fn send_context(
caller: &Caller,
t: &TargetFacts,
merged: &Merged,
slots: &ReachSlots,
armed: usize,
relation: Relation,
) -> SendContext {
let teams_scope = settings::env_value(merged, TEAMS_ENV)
.filter(|(value, _)| truthy(value))
.map(|(_, scope)| scope.to_string());
let caller_is_subagent = caller
.lane
.as_deref()
.is_some_and(|l| Some(l) != caller.session.as_deref());
SendContext {
caller_kind: caller.kind,
caller_is_subagent,
relation,
receiver_kind: t.kind,
receiver_state: t.state,
receiver_version: t.version.clone(),
headless: t.headless,
socket_present: t.socket_present,
lane: t.lane.clone(),
routing_id: t.routing_id.clone(),
mode: Mode::Steer,
queues: false,
resume: false,
official_only: false,
teams: GateVerdict::teams(
teams_scope.as_deref(),
caller::teams_dirs(),
t.teammate_lanes,
),
harbor: GateVerdict::harbor(t.socket_present),
chunks: 1,
best_slots: slots.best,
best_event: slots.best_event.clone(),
armed,
async_rewake_on_stop: slots.async_rewake_on_stop,
hooks_policy_switch: merged.policy_switch.map(str::to_string),
}
}
pub(crate) fn run_reach_to(target: &str, format: OutputFormat) -> Result<()> {
let caller = caller::classify(None)?;
let path = resolve_one(target)?;
let receiver = caller::probe_receiver(&path)?;
let merged = settings::merged(
&crate::path::claude_home()?,
receiver.cwd.as_deref().map(Path::new),
);
let slots = ReachSlots::read(&merged);
let armed = armed_slots(&receiver);
let relation = relation_of(&caller, &receiver);
let t = receiver_target(&receiver);
let ctx = send_context(&caller, &t, &merged, &slots, armed.len(), relation);
let decision = policy::decide(&ctx);
if caller.kind == SenderKind::Lane && !caller.lane_exact {
eprintln!("{LANE_ASSUMED_NOTE}");
}
let reach = Reach {
inference: inference_note(&t, &decision, &ctx),
lane: receiver.lane.clone(),
routing_id: receiver.routing_id.clone(),
session: receiver.session.clone(),
kind: receiver.kind,
state: receiver.state,
version: receiver.version.clone(),
caller_kind: caller.kind,
caller_label: caller_label(&caller),
decision,
slots,
armed,
teams: ctx.teams.clone(),
harbor: ctx.harbor.clone(),
settings: SettingsDisclosure::of(&merged),
};
match format {
OutputFormat::Text => {
render_reach_text(&reach);
Ok(())
}
OutputFormat::Json => render_reach_json(&reach),
}
}
pub(crate) fn inference_note(t: &TargetFacts, d: &Decision, ctx: &SendContext) -> Option<String> {
if t.kind == ReceiverKind::TopLevel {
return None;
}
let fallback = if d.queued && ctx.best_slots > 0 {
match ctx.mode {
Mode::Steer => "csift steer",
Mode::Queue => "csift queue",
}
} else {
"HELD"
};
Some(format!("{INFERENCE_NOTE}; fallback: {fallback}"))
}
fn caller_label(c: &Caller) -> String {
match c.kind {
SenderKind::Lane => c
.lane
.clone()
.or_else(|| c.session.clone())
.unwrap_or_else(|| "unknown".to_string()),
SenderKind::External => c.label.clone().unwrap_or_else(|| "unknown".to_string()),
}
}
fn armed_slots(r: &Receiver) -> Vec<u32> {
let root = channel_dir(&r.session_path.with_extension(""));
read_armed(&root, &r.lane)
.ok()
.flatten()
.map(|m| m.slots_seen.iter().copied().collect())
.unwrap_or_default()
}
pub(crate) fn resolve_one(target: &str) -> Result<PathBuf> {
let files = crate::path::resolve_session_files(
std::slice::from_ref(&PathBuf::from(target)),
crate::path::SubagentScope::TopLevelOnly,
crate::path::Caller::Other,
)?;
match files.as_slice() {
[one] => Ok(one.clone()),
[] => bail!("`{target}` resolved to no transcript: a reach question addresses one lane"),
many => bail!(
"`{target}` resolved to {} transcripts; a reach question addresses exactly ONE lane. \
Name it by its own id: {}",
many.len(),
many.iter()
.map(|p| crate::subagent::session_id_from_path(p))
.collect::<Vec<_>>()
.join(", ")
),
}
}