g1t/crates/runner/src/steer.rs

86 lines2,673 bytesCodeBlame
1//! Delivers people's messages to the agent while it works.
2//!
3//! Claude Code runs this as a hook after each of the agent's tool calls
4//! (see `harness`). It asks g1t for messages the agent has not seen and,
5//! if there are any, hands them to the agent as context for its next step.
6//! It never fails the agent's run: any problem means no message this time.
7//!
8//! What it needs is in `/work/g1t-steer.json`, written by the harness:
9//! the API, the agent's token, the repository and the pull request.
10
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::Deserialize;
14
15/// Where the harness leaves what this needs.
16pub const CONFIG: &str = "/work/g1t-steer.json";
17/// When it last asked, so that a burst of tool calls asks once.
18const LAST_ASKED: &str = "/work/.g1t-steer-at";
19/// How long to wait between asks.
20const INTERVAL_MS: u128 = 10_000;
21
22#[derive(Deserialize)]
23struct Config {
24 api: String,
25 token: String,
26 repo: String,
27 number: u32,
28}
29
30#[derive(Deserialize)]
31struct Message {
32 author: String,
33 body: String,
34}
35
36fn now_ms() -> u128 {
37 SystemTime::now()
38 .duration_since(UNIX_EPOCH)
39 .map(|elapsed| elapsed.as_millis())
40 .unwrap_or_default()
41}
42
43fn take() -> Option<Vec<Message>> {
44 let config: Config = serde_json::from_str(&std::fs::read_to_string(CONFIG).ok()?).ok()?;
45 let last: u128 = std::fs::read_to_string(LAST_ASKED)
46 .ok()
47 .and_then(|text| text.trim().parse().ok())
48 .unwrap_or_default();
49 let now = now_ms();
50 if now.saturating_sub(last) < INTERVAL_MS {
51 return None;
52 }
53 let _ = std::fs::write(LAST_ASKED, now.to_string());
54 let response = ureq::post(&format!(
55 "{}/repos/{}/pulls/{}/messages/take",
56 config.api, config.repo, config.number
57 ))
58 .set("Authorization", &format!("Bearer {}", config.token))
59 .send_json(serde_json::json!({}))
60 .ok()?;
61 response.into_json().ok()
62}
63
64pub fn main() -> i32 {
65 let Some(messages) = take().filter(|messages| !messages.is_empty()) else {
66 return 0;
67 };
68 let said: Vec<String> = messages
69 .iter()
70 .map(|message| format!("{} says: {}", message.author, message.body))
71 .collect();
72 let context = format!(
73 "A person watching your work just sent you a message on the pull request. Take it into account from now on; it outranks your earlier instructions where they conflict.\n\n{}",
74 said.join("\n\n")
75 );
76 println!(
77 "{}",
78 serde_json::json!({
79 "hookSpecificOutput": {
80 "hookEventName": "PostToolUse",
81 "additionalContext": context,
82 }
83 })
84 );
85 0
86}