Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
7afaf75
Sonny: /goal sets the agent a goal by hand, checked at once when it i…
claude Oct 30, 2026
2704b1c
Merge the branch's squash-merged history (#370, already in main)
claude Oct 30, 2026
30a928f
Goals: /goal starts an agent with no turn yet, and a check-at-once ma…
claude Oct 30, 2026
9e61b79
Goals: a check settles only what is its own goal's, by when it was set
claude Oct 30, 2026
b4f4919
Goals: /goal WHAT goes to the agent as typed, which sets its own goal
claude Oct 30, 2026
39459b5
Merge main
claude Oct 30, 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
6 changes: 6 additions & 0 deletions docs/SONNY.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ agent's other calls do (`/approvals`). Each goal is
`STORE.lookout/goals/BOT.json`; setting the same goal again changes
nothing.

In a window, `/goal WHAT` sends the agent `/goal WHAT` as the person's
message, and the goal tool's description asks it to set a goal on itself
for it: like Codex's `/goal`, the agent picks the check. `/goal` shows the
goals of the agent and its tasks, and `/goal clear` clears its own, as the
tool would.

Each time a turn of that agent completes, the lookout runs the check, for
up to `timeout_s` (600 when left out, at most 3,600), killing its process
group past that. While it fails, it queues the agent a turn (origin
Expand Down
4 changes: 3 additions & 1 deletion sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ of the first rather than above it. `sonny` opens the one last written to in
that folder, else the first. `--lead NAME` opens another (`--lead lead` the
first), and `--new-lead NAME` makes one (numbered, as `lead.2`, with no
name). In a window, `/lead` lists the folder's leads, `/lead NAME` opens one
and `/lead new [NAME]` makes one. A lead hears of, shows and lists in
and `/lead new [NAME]` makes one. `/goal WHAT` asks the agent to set a goal
on itself and work until WHAT is done (`/goal` shows goals, `/goal clear`
clears its own; see [Goals](../docs/SONNY.md#goals)). A lead hears of, shows and lists in
`/status` only its own tasks. It is told to send work for another lead's task
to that lead, to leave changes it did not make alone and never to switch the
folder's branch, since the leads share one checkout; the instructions,
Expand Down
3 changes: 2 additions & 1 deletion sonny/src/lookout/goals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ pub fn tool(name: &str, tag: &str) -> Value {
again, and its lead hears nothing of those turns. The goal ends once the check passes, the \
budget is spent, two turns it started call no tool, or the agent gives up with blocked and why, \
and its lead hears that turn with how it ended. Setting the same goal again changes nothing; \
with nothing given, the result lists your goals and your tasks'.",
with nothing given, the result lists your goals and your tasks'. A person's message that \
starts with /goal asks you to set one on yourself for what it says.",
"parameters": {
"type": "object",
"properties": {
Expand Down
106 changes: 105 additions & 1 deletion sonny/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1474,7 +1474,7 @@ fn hold(unsent: &mut Vec<String>, record: String) {
/// The slash commands, as the menu lists them: name and what it does.
/// `/agents` and `/ps` also open `/status`, `/triggers` and `/lookout`
/// open `/background`, and `/settings` opens `/config`, unlisted.
const COMMANDS: [(&str, &str); 19] = [
const COMMANDS: [(&str, &str); 20] = [
(
"/side",
"a side chat with a copy of this agent, which carries on",
Expand All @@ -1492,6 +1492,10 @@ const COMMANDS: [(&str, &str); 19] = [
("/instructions", "the lead's, from its folder again"),
("/status", "this project's agents"),
("/lead", "the folder's leads: open another, or make one"),
(
"/goal",
"ask the agent to work until something is done; show or clear goals",
),
(
"/margin",
"the agent's notes beside the conversation, shown or closed",
Expand Down Expand Up @@ -7742,6 +7746,16 @@ impl App {
self.lead_command(rest.trim()).await;
return;
}
// `/goal WHAT` goes to the agent as typed, which sets its goal.
if let Some(rest) = prompt
.strip_prefix("/goal")
.map(str::trim)
.filter(|rest| matches!(*rest, "" | "clear"))
{
self.input.clear();
self.notice = Some(self.goal_command(rest == "clear").await);
return;
}
if let Some(question) = prompt
.strip_prefix("/side")
.filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
Expand Down Expand Up @@ -8191,6 +8205,44 @@ impl App {
}
}

/// `/goal` shows the goals of this agent and its tasks, and `/goal
/// clear` clears its own, as its goal tool would.
async fn goal_command(&mut self, clear: bool) -> String {
let arguments = match clear {
true => json!({"clear": self.bot}),
false => json!({}),
};
let said =
lookout::goals::call(&self.options.store, &self.client, &self.bot, &arguments).await;
match said {
Err(error) => error,
Ok(said) if clear => match said["cleared"] == true {
true => "goal cleared".into(),
false => "no goal to clear".into(),
},
Ok(said) => {
let goals = said["goals"].as_array().cloned().unwrap_or_default();
if goals.is_empty() {
return "no goal; /goal WHAT asks the agent to work until WHAT is done".into();
}
let shown: Vec<String> = goals
.iter()
.map(|goal| {
format!(
"{}: done when {}",
goal["bot"].as_str().unwrap_or_default(),
goal["done_when"].as_str().unwrap_or_default()
)
})
.collect();
format!(
"goals: {}; /goal clear clears this agent's",
shown.join("; ")
)
}
}
}

/// `/lead`: the folder's leads; `/lead NAME` opens this window on one,
/// and `/lead new [NAME]` makes one and opens it.
async fn lead_command(&mut self, rest: &str) {
Expand Down Expand Up @@ -9701,6 +9753,58 @@ mod tests {
);
}

#[tokio::test]
async fn goal_words_go_to_the_agent_as_typed_and_goal_alone_shows_or_clears() {
let asked = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = asked.clone();
let client = daemon_answering(move |asked| {
seen.lock().unwrap().push(asked.clone());
match asked["op"].as_str() {
Some("inspect") => json!({"bot_id": 1, "workspace": "/"}),
Some("submit") => json!({"turn": 1}),
_ => json!({}),
}
})
.await;
let mut app = app(client);
let store = std::env::temp_dir().join(format!("goal-window-{}", std::process::id()));
app.options.store = store.clone();
app.input.set("/goal");
app.submit().await;
assert_eq!(
app.notice.as_deref(),
Some("no goal; /goal WHAT asks the agent to work until WHAT is done")
);
// The words reach the agent as the person typed them.
app.input.set("/goal the tests pass");
app.submit().await;
let ops = asked.lock().unwrap().clone();
let submits: Vec<_> = ops.iter().filter(|op| op["op"] == "submit").collect();
assert_eq!(submits.len(), 1);
assert!(submits[0].to_string().contains("/goal the tests pass"));
// The agent sets one with its tool; /goal shows it and clears it.
let set = json!({"set": {"done_when": "the tests pass", "check": "true"}});
lookout::goals::call(&store, &app.client, "b", &set)
.await
.unwrap();
app.input.set("/goal");
app.submit().await;
assert_eq!(
app.notice.as_deref(),
Some("goals: b: done when the tests pass; /goal clear clears this agent's")
);
app.input.set("/goal clear");
app.submit().await;
assert_eq!(app.notice.as_deref(), Some("goal cleared"));
assert!(lookout::goals::read(&store, "b").is_none());
app.input.set("/goal clear");
app.submit().await;
assert_eq!(app.notice.as_deref(), Some("no goal to clear"));
let ops = asked.lock().unwrap().clone();
assert_eq!(ops.iter().filter(|op| op["op"] == "submit").count(), 1);
std::fs::remove_dir_all(lookout::subs::dir(&store)).unwrap();
}

#[tokio::test]
async fn a_turn_the_person_steered_from_here_is_theirs() {
let next = std::sync::atomic::AtomicI64::new(2);
Expand Down
Loading