Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2cbd7cd
Goals: keep an agent working until a check command passes
claude Oct 29, 2026
263c4d6
Merge main into goals
claude Oct 29, 2026
2380cad
Goals: the lead-reaction test's lookout gets its goals
claude Oct 29, 2026
8fd8d02
Goals: answer Codex's review
claude Oct 29, 2026
99136ee
Merge main into goals
claude Oct 29, 2026
31231ed
Triggers: restamp reads the job file by its new name
claude Oct 29, 2026
bf1e342
Goals: answer Codex's second review
claude Oct 29, 2026
1a44323
Merge main into goals
claude Oct 29, 2026
e79ca99
Goals: a turn its check holds is not judged, and a goal given up whil…
claude Oct 29, 2026
97d3e40
Goals: a goal set again counts its turns without a tool from 0
claude Oct 29, 2026
6aa2f0e
Goals: a dropped connection stops the checks under way, which the rep…
claude Oct 29, 2026
e160377
Goals: a stopped check takes what it started with it, and a replayed …
claude Oct 29, 2026
025fab9
Goals: the lead hears how a goal ended in the lookout's words only
claude Oct 29, 2026
e849c96
Goals: a lookout giving way stops its checks, and only a heard task's…
claude Oct 29, 2026
09f8d93
Goals: a check runs where its turn worked, and how a goal ended survi…
claude Oct 29, 2026
81aa809
Goals: a check goes without every provider key an agent's shell does
claude Oct 29, 2026
82305f6
Goals: tests for a goal its lead sets, a turn that ends mid-check, an…
claude Oct 29, 2026
13863f4
Goals: a turn the lead's wait read still says how its goal ended
claude Oct 29, 2026
576f788
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 29, 2026
06ed6d2
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 29, 2026
cb125bc
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 29, 2026
acf562c
Goals: the held-turn test asks due() as main's lookout has it
claude Oct 29, 2026
a9f51b9
Goals: a check under way keeps the replay cursor before its turn, wat…
claude Oct 29, 2026
968a2e5
Goals: a wait that reads a turn leaves how its goal ended to tell
claude Oct 29, 2026
48a7962
Goals: a trigger passing a turn's answer on leaves how its goal ended…
claude Oct 29, 2026
107d22e
Goals: deleting an agent stops its check
claude Oct 29, 2026
e7083fd
Goals: a goal set again while its last one is checked is checked afresh
claude Oct 29, 2026
b3c9ede
Goals: how a goal ended is news even while the lead waits on its turn
claude Oct 29, 2026
7c24129
Daemon companions: commands the daemon keeps running for as long as i…
claude Oct 29, 2026
21f6720
Merge the branch's squash-merged history (#341, already in main)
claude Oct 29, 2026
2983abd
Companions: log setup off the runtime thread, the whole group stopped…
claude Oct 29, 2026
010016e
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 29, 2026
bcb379b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 29, 2026
39328ab
Companions: idle-exit counts from the last client's activity, a compa…
claude Oct 29, 2026
f0e8a4b
Companions: a session is a companion's or not once, as it opens
claude Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bench/socket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def close(self):


class SocketClient:
def __init__(self, binary, path, url, tools, extra=(), settings=None):
def __init__(self, binary, path, url, tools, extra=(), settings=None, env=None):
# Keep AF_UNIX paths short even in deep remote snapshot directories.
self.directory = tempfile.TemporaryDirectory(prefix='agent-bench-', dir='/tmp')
self.socket_path = Path(self.directory.name) / 'daemon.sock'
Expand All @@ -85,7 +85,7 @@ def __init__(self, binary, path, url, tools, extra=(), settings=None):
self.process = subprocess.Popen(
[str(binary), *serve_args(path, url, extra=extra), '--socket', str(self.socket_path)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True, env=clean_env())
start_new_session=True, env=env or clean_env())
try:
deadline = time.monotonic() + 5
while True:
Expand Down
36 changes: 35 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# CLI contract

Agent uses flat verbs: `run`, `follow`, `fork`, `interrupt`, `wait`, `ls`,
`inspect`, `turns`, `usage`, `rm`, `prune`, `approvals`, `answer`, `models`, `start`, `stats`,
`inspect`, `turns`, `usage`, `rm`, `prune`, `approvals`, `answer`, `models`, `companion`, `start`, `stats`,
`shutdown`, and `serve`. All named
bots use the same commands. There is no parent/child command hierarchy.

Expand Down Expand Up @@ -177,6 +177,40 @@ for both client commands and `serve`. Keep that registry, the implementation,
and behavior tests aligned when adding an option. No CLI framework or runtime
dependency is required.

## Companions

A companion is a command the daemon keeps running for as long as it runs:
a client's helper that agents wait on, such as Sonny's lookout or the
approver, so it lives as long as the daemon whoever started either. The
daemon knows nothing of what one is.

```sh
agent companion add NAME [--cwd DIR] [--env KEY=VALUE]... -- COMMAND [ARGS]...
agent companion rm NAME
agent companion ls
```

`add` keeps the companion in the store under NAME (1 to 64 letters,
digits, `.`, `_` or `-`, not starting with `.`) and starts it now; every
later daemon on that store starts it once it listens. The same spec again
changes nothing (`changed: false`); another spec under the name replaces
the old one, which is stopped first. `rm` stops and forgets one
(`removed: false` when there was none); `ls` lists them, as `stats` does
under `companions`, each with its spec, `running`, `pid`, `started_ms`,
`restarts` and `last_exit` (`code` and `signal`, or `spawn_failed` with
why, and `at_ms`). Each runs in its own process group with the daemon's
environment, its `--env` over that, and `AGENT_STORE`, `AGENT_SOCKET`,
`AGENT_BIN` and `AGENT_COMPANION` (its name), never a bot shell's marks;
its stdin is empty and its output goes to `STORE.companions/NAME.log`. One
that exits starts again after 1 s, the wait doubling up to 60 s while it
keeps exiting within a minute of starting. The daemon's shutdown stops
each with SIGTERM to its group, then SIGKILL after 5 s. A companion's own
sessions keep no daemon from `--idle-exit`, and a daemon on stdio, with no
socket to reach it on, runs none. The request is `companions` with `add`
(`name`, `command`, `args`, `env`, `cwd`), `remove` (a name) or neither;
a sandboxed bot's commands may not make it, since a companion runs as the
daemon does.

## Output and status

Default output is machine-readable JSON. `run` and `follow` stream one JSON
Expand Down
18 changes: 11 additions & 7 deletions docs/LOOKOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,23 @@ and the rest, `sonny/src/served.rs`).

## One lookout a daemon

Sonny starts `sonny lookout --store STORE --socket SOCKET` detached when it
opens a project, and again after the daemon restarts. A lookout holds
`sonny lookout --store STORE --socket SOCKET` runs as the daemon's
companion `sonny-lookout` (CLI.md, Companions): Sonny adds it when it opens
a project, and the daemon keeps it running for as long as it runs, starts
it again if it exits, and starts it with every later daemon on that store,
whether or not a window is open. A lookout holds
`STORE.lookout.lock` while it runs, so a second one prints
`{"event":"already_watching"}` and exits, unless another build of Sonny
started the one running, as an upgrade leaves it: that one is stopped, and
the new one takes its place, so the tools the new Sonny gives its agents
are answered. `STORE.lookout.sonny` says which build runs. It watches for every bot with
are answered; the daemon's companion takes the place of one a window
started detached, of any build. `STORE.lookout.sonny` says which build runs. It watches for every bot with
subscriptions on the daemon, whichever Sonny opened it, so a lead hears
from its tasks even while no window shows that project. It exits
once the daemon has been unreachable for 30 seconds; the Sonny that starts
the next daemon starts the next lookout. Its own lines go to
`STORE.lookout.log`, and each subscriber it watches (`seated`) and each
note it sends (`woke`) to `STORE.lookout.jsonl`.
once the daemon has been unreachable for 30 seconds, and the daemon stops
it as it stops. Its own lines go to `STORE.companions/sonny-lookout.log`,
and each subscriber it watches (`seated`) and each note it sends (`woke`)
to `STORE.lookout.jsonl`.

It keeps a cursor in `STORE.lookout.cursor`: the newest event it saw, or
just before the oldest one whose news it has not told yet. A lookout that
Expand Down
11 changes: 7 additions & 4 deletions sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ folder under `~/.agent`.

`~/.agent/env` (`KEY=VALUE` lines, readable only by you) holds the provider
keys, and `AWS_PROFILE` for Bedrock, which Sonny applies over your shell's to
the daemon, the approver and the lookout. Settings go in
the daemon it starts; the approver and the lookout run as that daemon's
companions, with its environment, so they live as long as it does whether
or not a window is open. Settings go in
`~/.agent/config.toml`, as Codex keeps them: `provider` (the daemon's
`--provider` specs, after the shell's `AGENT_PROVIDER`), `model` (a new
agent's when none is named) and the efforts below. When no provider is named
Expand Down Expand Up @@ -176,7 +178,7 @@ notifies, `herdr agent wait` and `herdr agent prompt` reach it by name
0.9.2 lists Sonny but takes no command to restart it). Sonny reports from a
pane of the Herdr server on its own machine: one reached by plain `ssh` from a
Herdr pane is not listed, one in a remote machine Herdr itself connects to is.
The daemon, lookout and approver Sonny starts run in a session of their own,
The daemon Sonny starts, and the lookout and approver it keeps, run in a session of their own,
so closing their pane or stopping Herdr leaves them and their turns running.
In cmux, which reads OSC 7501 and OSC 777 itself, the workspace's sidebar row
shows which task is working, waiting on an approval or done, and a
Expand All @@ -188,8 +190,9 @@ then cmux offers it to run by hand); quitting Sonny clears it. cmux lets
only programs started in its tabs reach it, by default; agents' shells run
in the daemon, not in cmux, so set cmux's Settings > Automation to
"Automation mode" (your own programs, no ancestry check) for agents to run
commands in tabs and read them. The lookout Sonny starts in cmux reaches it
either way; one started outside cmux needs Automation mode too.
commands in tabs and read them. The lookout, the daemon's companion, reaches it
either way when the daemon started from a cmux tab; otherwise it needs
Automation mode too.
The place a
project was laid out in is kept in `~/.agent/sonny/places`, so an agent's
`run_in_pane` and `show_agent` calls go to that Herdr or cmux workspace or
Expand Down
8 changes: 4 additions & 4 deletions sonny/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1500,14 +1500,14 @@ async fn services(
if let Some(project) = project {
lookout::subscribe_lead(&agent.options.store, bot, project)?;
}
crate::start_lookout(&agent.options)?;
crate::start_lookout(client).await?;
if approvals::mode(&record) == Some("auto") {
let model = format!(
"{}/{}",
record["provider"].as_str().unwrap_or(""),
record["model"].as_str().unwrap_or("")
);
crate::serve_auto(client, &agent.options, &model).await?;
crate::serve_auto(client, &model).await?;
}
Ok(())
}
Expand Down Expand Up @@ -1586,14 +1586,14 @@ async fn setup_lead(
}
}
lookout::subscribe_lead(&agent.options.store, &lead.bot, &lead.project)?;
crate::start_lookout(&agent.options)?;
crate::start_lookout(client).await?;
if gating.or(mode) == Some("auto") {
let model = format!(
"{}/{}",
record["provider"].as_str().unwrap_or(""),
record["model"].as_str().unwrap_or("")
);
crate::serve_auto(client, &agent.options, &model).await?;
crate::serve_auto(client, &model).await?;
}
Ok(())
}
Expand Down
54 changes: 45 additions & 9 deletions sonny/src/lookout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ fn beside(store: &Path, suffix: &str) -> PathBuf {

/// This Sonny, told apart from another build of it: its version and when
/// its binary was written.
fn identity() -> String {
pub(crate) fn identity() -> String {
let written = std::env::current_exe()
.and_then(std::fs::metadata)
.and_then(|meta| meta.modified())
Expand All @@ -308,7 +308,9 @@ fn identity() -> String {
/// when the lookout holding it is this Sonny's own.
fn replace(store: &Path) -> Result<Option<std::fs::File>, String> {
let theirs = std::fs::read_to_string(beside(store, ".lookout.sonny")).unwrap_or_default();
if theirs == identity() {
// The daemon's own companion takes the place of one a window started
// before Sonny kept it as one, of this build too.
if theirs == identity() && std::env::var_os("AGENT_COMPANION").is_none() {
return Ok(None);
}
let pid: Option<i32> = std::fs::read_to_string(beside(store, ".lookout.lock"))
Expand Down Expand Up @@ -343,8 +345,14 @@ fn lock(store: &std::path::Path) -> Result<Option<std::fs::File>, String> {
.open(&path)
.map_err(|e| format!("{}: {e}", std::path::Path::new(&path).display()))?;
// SAFETY: flock on a descriptor `file` owns and keeps open; the lock is
// released when the process exits.
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
// released when the process exits. Tried twice, since `running` holds
// it for an instant.
let take = || unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
let taken = take() || {
std::thread::sleep(Duration::from_millis(50));
take()
};
if taken {
// Its process, which `/status` asks after.
use std::io::Write;
let _ = file.set_len(0);
Expand Down Expand Up @@ -1010,14 +1018,24 @@ fn cursor_path(store: &Path) -> PathBuf {
/// The most of the log `status` reads, from its end.
const STATUS_BYTES: u64 = 1 << 20;

/// Whether a lookout watches `store`'s daemon: one holds its lock. The pid
/// the file names is no answer, since after a restart another process may
/// have it. The lock is tried shared and let go at once; a lookout
/// starting meanwhile tries again (`lock`).
pub fn running(store: &std::path::Path) -> bool {
use std::os::fd::AsRawFd;
let Ok(file) = std::fs::File::open(beside(store, ".lookout.lock")) else {
return false;
};
// SAFETY: flock on a descriptor `file` owns; closing it lets go.
let taken = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0;
!taken && std::io::Error::last_os_error().raw_os_error() == Some(libc::EWOULDBLOCK)
}

/// One line on the lookout for a subscriber, as `/status` shows it:
/// whether one runs and watches it, and today's notes.
pub fn status(store: &std::path::Path, lead: &str) -> String {
let pid: Option<i32> = std::fs::read_to_string(beside(store, ".lookout.lock"))
.ok()
.and_then(|pid| pid.trim().parse().ok());
// SAFETY: signal 0 sends nothing; it only asks whether the process lives.
if !pid.is_some_and(|pid| pid > 0 && unsafe { libc::kill(pid, 0) } == 0) {
if !running(store) {
return "Lookout · not running".to_owned();
}
let (mut seated, mut notes) = (false, 0);
Expand Down Expand Up @@ -2339,6 +2357,24 @@ mod tests {
assert!(lookout.updates.due());
}

#[test]
fn a_lookout_runs_while_one_holds_its_lock_whatever_pid_it_names() {
let dir = std::env::temp_dir().join(format!("lookout-running-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let store = dir.join("state.sqlite");
assert!(!running(&store));
// A lookout gone before a restart, its pid now another process's.
std::fs::write(dir.join("state.sqlite.lookout.lock"), "1").unwrap();
assert!(!running(&store));
let held = lock(&store).unwrap().unwrap();
assert!(running(&store));
// Asking leaves it held.
assert!(lock(&store).unwrap().is_none());
drop(held);
assert!(!running(&store));
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn status_says_whether_it_watches_from_the_start() {
let dir = std::env::temp_dir().join(format!("lookout-status-{}", std::process::id()));
Expand Down
Loading
Loading