use crate::config::Config;
use crate::paths::Paths;
use crate::seed;
use anyhow::Result;
use std::fs;
use std::process::ExitCode;
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
const CONTRACT: &str = r#"# ⚑ WORKER CONTRACT (auto-injected — must obey)
- Never send notifications (no terminal-notifier or any OS notification). You are
an agent; surface anything a human must see by ASKing (below) — the human sees
it through whatever client they run.
- When you need a human decision / info / approval, do NOT guess — ASK and WAIT.
This ONE command writes your question to the mailbox and BLOCKS until the root
agent (or human) answers, printing the answer to stdout:
answer=$("$LOOOP_BIN" _ ask __ID__ --prompt "<what you need to know>")
(optionally --ref reports/x.md and/or --options a,b). Use $answer and continue.
You do NOT need a terminal, stdin, or attach — just call it and read its output.
Ask once per question; it returns only when answered.
- When the task is 100% complete and nothing is waiting, end your own session:
"$LOOOP_BIN" _ kill __ID__
(this lets the pulse prune the corpse). NEVER do this mid-task or while waiting
on a human.
- LEASE (ONLY if the PLAYBOOK/goal tells you to claim this task) — announce
ownership BEFORE any work so a tick or sibling can't duplicate/race you:
"$LOOOP_BIN" _ claim <name> # atomic test-and-set; <name> defined by the goal (e.g. one per repo)
This EXITS NON-ZERO if a live session already holds <name> — if so, do NOT
proceed: flag the human or pick other work, never race the holder. Release it
the instant the task is fully done, right before the kill above:
"$LOOOP_BIN" _ unclaim <name>
If you crash the pulse auto-reaps your claim; on a clean finish YOU release it.
NEVER sit/sleep/poll while holding a claim — act and move on.
- SINGLE-WRITER DATA DIR: the pulse (the tick AI) is the SOLE writer of the
policy files — PLAYBOOK.md, goals/ and sensors/. By default you write ONLY to
claims/ (your lease), reports/ (deliverables) and your own code sandbox. Do
NOT edit PLAYBOOK/goals/sensors: a concurrent tick reads them every beat, so a
racing writer tears the loop's state. If your task implies a policy change,
write the proposal to reports/<id>.md and raise a flag — the human (or the
next tick) applies it. EXCEPTION: if your task is explicitly a meta task (e.g.
setup or playbook grooming), you MAY edit those files, but you MUST show the
diff and `"$LOOOP_BIN" _ flag` for human approval BEFORE writing. When unsure whether
your task is meta, treat the data dir as read-only and propose via reports/.
- WORKSPACE: you start in the loop data dir (read-only context for you, save the
meta exception above). If your task touches a code repo, provision your OWN
sandbox FIRST and cd into it — never edit code in the data dir:
• if `box` is available: box new __SESSION__ --repo <repo> && cd "$(box switch __SESSION__)"
• otherwise (git): git -C <local-clone> worktree add /tmp/__SESSION__ -b looop/__SESSION__ && cd /tmp/__SESSION__
(the PLAYBOOK names the repos and which to prefer.)
- COST: when you end your session (right before `looop _ kill`), record this
session's total LLM spend so the human can see it in `looop cost`. If you can
determine your own USD cost for this run, log it:
"$LOOOP_BIN" _ cost session __ID__ __RUNNER__ <usd>
(e.g. "$LOOOP_BIN" _ cost session __ID__ __RUNNER__ 0.42). Skip only if you truly
cannot determine the amount.
- DELIVERABLES: write any report / artifact a human will read into the data dir's
reports/ folder (e.g. reports/<id>.md). That dir PERSISTS across ticks. NEVER
write deliverables to snapshots/ — the pulse wipes snapshots/ on EVERY beat, so
anything you leave there vanishes before the human sees it. Reference the
reports/ path in your flag note so I know where to look.
---
"#;
pub fn cmd_start_session(paths: &Paths, args: &[String]) -> Result<ExitCode> {
seed::ensure_dirs(paths)?;
let Some(id) = args.first() else {
eprintln!("usage: looop start-session <id> <prompt> [runner]");
return Ok(ExitCode::from(1));
};
let Some(prompt) = args.get(1) else {
eprintln!("missing prompt");
return Ok(ExitCode::from(1));
};
let cfg = Config::load(paths)?;
let runner = args
.get(2)
.cloned()
.or_else(|| cfg.default_runner())
.unwrap_or_default();
let Some(tmpl) = cfg.runner_cmd(&runner, "interactive") else {
eprintln!("start-session: unknown runner '{runner}'");
return Ok(ExitCode::from(1));
};
if id.as_str() == PULSE_SESSION {
eprintln!("start-session: '{id}' is reserved for the pulse; pick another id");
return Ok(ExitCode::from(1));
}
let session = id.clone();
if status_exists(paths, &session) {
if is_alive(paths, &session) {
eprintln!("start-session: session {session} is already running");
return Ok(ExitCode::from(1));
}
reap(paths, &session); }
let prompt_file = paths.prompts_dir().join(format!("{session}.md"));
let contract = CONTRACT
.replace("__SESSION__", &session)
.replace("__ID__", id)
.replace("__RUNNER__", &runner);
fs::write(&prompt_file, format!("{contract}{prompt}\n"))?;
let cmd = tmpl.replace("{{prompt_file}}", &prompt_file.to_string_lossy());
let launch = format!(
"export LOOOP_SESSION_ID={}; cd {} && {cmd}",
shell_quote(&session),
shell_quote(&paths.data_dir.to_string_lossy())
);
spawn_detached(
paths,
vec!["bash".to_string(), "-c".to_string(), launch],
&session,
)?;
println!(
"started {session} (runner: {runner}, cwd: {})",
paths.data_dir.display()
);
println!(" watch: looop attach {id}");
Ok(ExitCode::SUCCESS)
}
fn full_session(id: &str) -> String {
id.strip_prefix("looop-").unwrap_or(id).to_string()
}
fn reject_pulse(session: &str, verb: &str) -> bool {
if session == PULSE_SESSION {
eprintln!(
"looop {verb}: '{PULSE_SESSION}' is the control loop, not a worker — observe it with \
`looop watch {PULSE_SESSION}` / `looop log {PULSE_SESSION}`, start it by running \
`looop` (Ctrl-C stops it)"
);
true
} else {
false
}
}
pub fn cmd_kill(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let Some(id) = args.first() else {
eprintln!("usage: looop _ kill <id>");
return Ok(ExitCode::from(1));
};
let session = full_session(id);
if reject_pulse(&session, "kill") {
return Ok(ExitCode::from(1));
}
kill(paths, &session)?;
Ok(ExitCode::SUCCESS)
}
pub fn cmd_send(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let mut newline = true;
let mut rest: Vec<&String> = Vec::new();
for a in args {
match a.as_str() {
"--no-newline" | "-n" => newline = false,
_ => rest.push(a),
}
}
let Some((id, words)) = rest.split_first() else {
eprintln!("usage: looop _ send <id> <text…> [--no-newline]");
return Ok(ExitCode::from(1));
};
if words.is_empty() {
eprintln!("usage: looop _ send <id> <text…> [--no-newline]");
return Ok(ExitCode::from(1));
}
let session = full_session(id);
if reject_pulse(&session, "send") {
return Ok(ExitCode::from(1));
}
let text = words
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" ");
rt().block_on(
paths
.sessions()
.send(Some(session.clone()), text, newline, false),
)?;
println!("sent to {session}");
Ok(ExitCode::SUCCESS)
}
pub fn cmd_screenshot(paths: &Paths, args: &[String]) -> Result<ExitCode> {
use ::babysit::cli::ShotFormat;
let mut format = ShotFormat::Plain;
let mut trim = true;
let mut id = None;
for a in args {
match a.as_str() {
"--ansi" => format = ShotFormat::Ansi,
"--json" => format = ShotFormat::Json,
"--plain" => format = ShotFormat::Plain,
"--no-trim" => trim = false,
_ if id.is_none() => id = Some(a),
_ => {}
}
}
let Some(id) = id else {
eprintln!("usage: looop _ screenshot <id> [--ansi|--json] [--no-trim]");
return Ok(ExitCode::from(1));
};
let session = full_session(id);
rt().block_on(paths.sessions().screenshot(Some(session), format, trim))?;
Ok(ExitCode::SUCCESS)
}
pub const PULSE_SESSION: &str = "pulse";
fn rt() -> &'static tokio::runtime::Runtime {
use std::sync::OnceLock;
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("looop: failed to build tokio runtime")
})
}
#[derive(Debug, Default)]
pub struct Session {
pub id: String,
pub state: String,
pub alive: bool,
pub exit_code: Option<i64>,
pub last_change: String,
}
impl Session {
pub fn is_pulse(&self) -> bool {
self.id == PULSE_SESSION
}
pub fn idle_for(&self) -> Option<std::time::Duration> {
let ts = chrono::DateTime::parse_from_rfc3339(self.last_change.trim()).ok()?;
(chrono::Utc::now() - ts.with_timezone(&chrono::Utc))
.to_std()
.ok()
}
}
fn project(info: ::babysit::SessionInfo) -> Session {
Session {
id: info.id,
state: info.state,
alive: info.alive,
exit_code: info.exit_code.map(|c| c as i64),
last_change: info.last_change,
}
}
pub fn list(paths: &Paths) -> Vec<Session> {
match rt().block_on(paths.sessions().list_sessions()) {
Ok(sessions) => sessions.into_iter().map(project).collect(),
Err(_) => Vec::new(),
}
}
pub fn list_workers(paths: &Paths) -> Vec<Session> {
list(paths).into_iter().filter(|s| !s.is_pulse()).collect()
}
fn corpse_dead(state: Option<::babysit::session::State>, alive: bool) -> bool {
use ::babysit::session::State;
match state {
Some(State::Exited | State::Killed) => true,
Some(State::Starting | State::Running) if !alive => true,
None if !alive => true,
_ => false,
}
}
pub fn prune_aged(paths: &Paths, max_age: std::time::Duration) {
use ::babysit::session;
let bs = paths.sessions();
rt().block_on(async {
let ids = match session::list_ids(&bs).await {
Ok(ids) => ids,
Err(_) => return,
};
for id in ids {
let Ok(meta) = session::read_meta(&bs, &id).await else {
continue; };
let status = session::read_status(&bs, &id).await.ok();
let alive = session::is_pid_alive(meta.babysit_pid);
if !corpse_dead(status.as_ref().map(|s| s.state), alive) {
continue;
}
let dir = bs.session_dir(&id);
let old = max_age.is_zero()
|| tokio::fs::metadata(&dir)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.elapsed().ok())
.map(|age| age >= max_age)
.unwrap_or(false);
if old {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
}
});
}
pub fn reap(paths: &Paths, session: &str) {
use ::babysit::session;
let bs = paths.sessions();
rt().block_on(async {
let Ok(meta) = session::read_meta(&bs, session).await else {
return;
};
let status = session::read_status(&bs, session).await.ok();
let alive = session::is_pid_alive(meta.babysit_pid);
if corpse_dead(status.as_ref().map(|s| s.state), alive) {
let _ = tokio::fs::remove_dir_all(bs.session_dir(session)).await;
}
});
}
pub fn status_exists(paths: &Paths, session: &str) -> bool {
list(paths).iter().any(|s| s.id == session)
}
pub fn kill(paths: &Paths, session: &str) -> anyhow::Result<()> {
rt().block_on(paths.sessions().kill(Some(session.to_string()), false))
}
pub fn kill_quiet(paths: &Paths, session: &str) -> anyhow::Result<()> {
suppress_stdout(|| kill(paths, session))
}
pub fn spawn_detached(paths: &Paths, cmd: Vec<String>, session: &str) -> anyhow::Result<()> {
let bs = paths.sessions();
suppress_stdout(|| {
rt().block_on(bs.run(
cmd,
Some(session.to_string()),
true, None, false, None, None, None, true, ))
})
.map(|_code| ())
}
pub fn run_detached_worker(args: &[String]) -> anyhow::Result<i32> {
use anyhow::Context;
let mut id = None;
let mut root = None;
let mut no_tty = false;
let mut timeout = None;
let mut idle_timeout = None;
let mut size = None;
let mut cmd: Vec<String> = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--detached-id" => id = it.next().cloned(),
"--root" => root = it.next().cloned(),
"--no-tty" => no_tty = true,
"--timeout" => timeout = it.next().cloned(),
"--idle-timeout" => idle_timeout = it.next().cloned(),
"--size" => size = it.next().cloned(),
"--" => {
cmd = it.by_ref().cloned().collect();
break;
}
_ => {} }
}
let id = id.context("looop run --detached-id: missing worker id")?;
let root = root.context("looop run --detached-id: missing --root")?;
let bs = ::babysit::Babysit::new(root);
rt().block_on(bs.run(
cmd,
None,
false,
Some(id),
no_tty,
timeout,
idle_timeout,
size,
false,
))
}
#[cfg(unix)]
pub(crate) fn suppress_stdout<T>(f: impl FnOnce() -> T) -> T {
use std::io::Write;
use std::os::unix::io::AsRawFd;
unsafe extern "C" {
fn dup(fd: i32) -> i32;
fn dup2(a: i32, b: i32) -> i32;
fn close(fd: i32) -> i32;
}
let Ok(devnull) = std::fs::OpenOptions::new().write(true).open("/dev/null") else {
return f();
};
let _ = std::io::stdout().flush();
unsafe {
let saved = dup(1);
if saved < 0 {
return f();
}
dup2(devnull.as_raw_fd(), 1);
let out = f();
let _ = std::io::stdout().flush();
dup2(saved, 1);
close(saved);
out
}
}
#[cfg(not(unix))]
pub(crate) fn suppress_stdout<T>(f: impl FnOnce() -> T) -> T {
f()
}
pub fn is_alive(paths: &Paths, session: &str) -> bool {
list(paths).iter().any(|s| s.id == session && s.alive)
}
pub fn await_alive(paths: &Paths, session: &str, timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
loop {
if is_alive(paths, session) {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sess(id: &str) -> Session {
Session {
id: id.to_string(),
..Default::default()
}
}
#[test]
fn pulse_is_recognized() {
assert!(sess(PULSE_SESSION).is_pulse());
assert!(!sess("triage").is_pulse());
}
}