@masyv/relay 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/core/Cargo.toml +51 -0
- package/core/src/agents/codex.rs +91 -0
- package/core/src/agents/gemini.rs +114 -0
- package/core/src/agents/mod.rs +86 -0
- package/core/src/agents/ollama.rs +98 -0
- package/core/src/agents/openai.rs +85 -0
- package/core/src/capture/git.rs +66 -0
- package/core/src/capture/mod.rs +41 -0
- package/core/src/capture/session.rs +217 -0
- package/core/src/capture/todos.rs +104 -0
- package/core/src/detect/mod.rs +80 -0
- package/core/src/handoff/mod.rs +156 -0
- package/core/src/lib.rs +198 -0
- package/core/src/main.rs +331 -0
- package/hooks/rate-limit.sh +19 -0
- package/package.json +38 -0
- package/scripts/build.sh +5 -0
- package/scripts/install.sh +12 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//! OpenAI GPT agent adapter.
|
|
2
|
+
|
|
3
|
+
use super::Agent;
|
|
4
|
+
use crate::{AgentStatus, HandoffResult, OpenAIConfig};
|
|
5
|
+
use anyhow::Result;
|
|
6
|
+
|
|
7
|
+
pub struct OpenAIAgent {
|
|
8
|
+
api_key: Option<String>,
|
|
9
|
+
model: String,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl OpenAIAgent {
|
|
13
|
+
pub fn new(config: &OpenAIConfig) -> Self {
|
|
14
|
+
let api_key = config.api_key.clone()
|
|
15
|
+
.or_else(|| std::env::var("OPENAI_API_KEY").ok());
|
|
16
|
+
Self {
|
|
17
|
+
api_key,
|
|
18
|
+
model: config.model.clone(),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl Agent for OpenAIAgent {
|
|
24
|
+
fn name(&self) -> &str { "openai" }
|
|
25
|
+
|
|
26
|
+
fn check_available(&self) -> AgentStatus {
|
|
27
|
+
match &self.api_key {
|
|
28
|
+
Some(_) => AgentStatus {
|
|
29
|
+
name: "openai".into(),
|
|
30
|
+
available: true,
|
|
31
|
+
reason: format!("API key configured, model: {}", self.model),
|
|
32
|
+
version: Some(self.model.clone()),
|
|
33
|
+
},
|
|
34
|
+
None => AgentStatus {
|
|
35
|
+
name: "openai".into(),
|
|
36
|
+
available: false,
|
|
37
|
+
reason: "No API key. Set OPENAI_API_KEY env var or add to config.toml".into(),
|
|
38
|
+
version: None,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn execute(&self, handoff_prompt: &str, _project_dir: &str) -> Result<HandoffResult> {
|
|
44
|
+
let api_key = self.api_key.as_ref()
|
|
45
|
+
.ok_or_else(|| anyhow::anyhow!("No OpenAI API key"))?;
|
|
46
|
+
|
|
47
|
+
let body = serde_json::json!({
|
|
48
|
+
"model": self.model,
|
|
49
|
+
"messages": [
|
|
50
|
+
{
|
|
51
|
+
"role": "system",
|
|
52
|
+
"content": "You are a coding assistant picking up work from a Claude Code session that hit its rate limit. Follow the handoff instructions precisely. Be efficient and direct — the user has a deadline."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"role": "user",
|
|
56
|
+
"content": handoff_prompt
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"max_tokens": 4096
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let resp = ureq::post("https://api.openai.com/v1/chat/completions")
|
|
63
|
+
.set("Authorization", &format!("Bearer {api_key}"))
|
|
64
|
+
.set("Content-Type", "application/json")
|
|
65
|
+
.send_json(&body)?;
|
|
66
|
+
|
|
67
|
+
let resp_json: serde_json::Value = resp.into_json()?;
|
|
68
|
+
let text = resp_json
|
|
69
|
+
.get("choices")
|
|
70
|
+
.and_then(|c| c.get(0))
|
|
71
|
+
.and_then(|c| c.get("message"))
|
|
72
|
+
.and_then(|m| m.get("content"))
|
|
73
|
+
.and_then(|c| c.as_str())
|
|
74
|
+
.unwrap_or("(no response)");
|
|
75
|
+
|
|
76
|
+
println!("{text}");
|
|
77
|
+
|
|
78
|
+
Ok(HandoffResult {
|
|
79
|
+
agent: "openai".into(),
|
|
80
|
+
success: true,
|
|
81
|
+
message: format!("OpenAI ({}) responded", self.model),
|
|
82
|
+
handoff_file: None,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//! Capture git state: branch, diff, status, recent commits.
|
|
2
|
+
|
|
3
|
+
use crate::GitState;
|
|
4
|
+
use anyhow::{Context, Result};
|
|
5
|
+
use std::path::Path;
|
|
6
|
+
use std::process::Command;
|
|
7
|
+
|
|
8
|
+
pub fn capture_git_state(project_dir: &Path) -> Result<GitState> {
|
|
9
|
+
let branch = run_git(project_dir, &["branch", "--show-current"])?
|
|
10
|
+
.trim()
|
|
11
|
+
.to_string();
|
|
12
|
+
|
|
13
|
+
let status = run_git(project_dir, &["status", "--short"])?;
|
|
14
|
+
let uncommitted_files: Vec<String> = status
|
|
15
|
+
.lines()
|
|
16
|
+
.map(|l| l.trim().to_string())
|
|
17
|
+
.filter(|l| !l.is_empty())
|
|
18
|
+
.collect();
|
|
19
|
+
|
|
20
|
+
let status_summary = if uncommitted_files.is_empty() {
|
|
21
|
+
"Clean working tree".to_string()
|
|
22
|
+
} else {
|
|
23
|
+
format!("{} uncommitted changes", uncommitted_files.len())
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Recent commits (last 5, one-line)
|
|
27
|
+
let log = run_git(
|
|
28
|
+
project_dir,
|
|
29
|
+
&["log", "--oneline", "-5", "--no-decorate"],
|
|
30
|
+
)
|
|
31
|
+
.unwrap_or_default();
|
|
32
|
+
let recent_commits: Vec<String> = log
|
|
33
|
+
.lines()
|
|
34
|
+
.map(|l| l.trim().to_string())
|
|
35
|
+
.filter(|l| !l.is_empty())
|
|
36
|
+
.collect();
|
|
37
|
+
|
|
38
|
+
// Diff summary (stat, not full diff — keeps handoff small)
|
|
39
|
+
let diff_summary = run_git(project_dir, &["diff", "--stat", "HEAD"])
|
|
40
|
+
.unwrap_or_default()
|
|
41
|
+
.trim()
|
|
42
|
+
.to_string();
|
|
43
|
+
|
|
44
|
+
Ok(GitState {
|
|
45
|
+
branch,
|
|
46
|
+
status_summary,
|
|
47
|
+
recent_commits,
|
|
48
|
+
diff_summary,
|
|
49
|
+
uncommitted_files,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn run_git(dir: &Path, args: &[&str]) -> Result<String> {
|
|
54
|
+
let output = Command::new("git")
|
|
55
|
+
.current_dir(dir)
|
|
56
|
+
.args(args)
|
|
57
|
+
.output()
|
|
58
|
+
.context("failed to run git")?;
|
|
59
|
+
|
|
60
|
+
if !output.status.success() {
|
|
61
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
62
|
+
anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
66
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
pub mod git;
|
|
2
|
+
pub mod session;
|
|
3
|
+
pub mod todos;
|
|
4
|
+
|
|
5
|
+
use crate::SessionSnapshot;
|
|
6
|
+
use anyhow::Result;
|
|
7
|
+
use std::path::Path;
|
|
8
|
+
|
|
9
|
+
/// Capture a full session snapshot from the current working directory.
|
|
10
|
+
pub fn capture_snapshot(
|
|
11
|
+
project_dir: &Path,
|
|
12
|
+
deadline: Option<&str>,
|
|
13
|
+
) -> Result<SessionSnapshot> {
|
|
14
|
+
let git_state = git::capture_git_state(project_dir).ok();
|
|
15
|
+
|
|
16
|
+
// Try to read Claude session state
|
|
17
|
+
let session_info = session::read_latest_session(project_dir);
|
|
18
|
+
|
|
19
|
+
// Try to read todo state
|
|
20
|
+
let todos = todos::read_todos(project_dir);
|
|
21
|
+
|
|
22
|
+
let recent_files = git_state
|
|
23
|
+
.as_ref()
|
|
24
|
+
.map(|g| g.uncommitted_files.clone())
|
|
25
|
+
.unwrap_or_default();
|
|
26
|
+
|
|
27
|
+
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
28
|
+
|
|
29
|
+
Ok(SessionSnapshot {
|
|
30
|
+
current_task: session_info.current_task,
|
|
31
|
+
todos,
|
|
32
|
+
decisions: session_info.decisions,
|
|
33
|
+
last_error: session_info.last_error,
|
|
34
|
+
last_output: session_info.last_output,
|
|
35
|
+
git_state,
|
|
36
|
+
project_dir: project_dir.to_string_lossy().to_string(),
|
|
37
|
+
recent_files,
|
|
38
|
+
timestamp,
|
|
39
|
+
deadline: deadline.map(String::from),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
//! Read Claude Code session state from .jsonl transcript files.
|
|
2
|
+
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
/// Extracted session info from Claude's transcript.
|
|
6
|
+
pub struct SessionInfo {
|
|
7
|
+
pub current_task: String,
|
|
8
|
+
pub decisions: Vec<String>,
|
|
9
|
+
pub last_error: Option<String>,
|
|
10
|
+
pub last_output: Option<String>,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Read the latest Claude session transcript and extract state.
|
|
14
|
+
pub fn read_latest_session(project_dir: &Path) -> SessionInfo {
|
|
15
|
+
// Claude stores transcripts in ~/.claude/projects/<project_hash>/<session>.jsonl
|
|
16
|
+
// Try to find and parse the latest one
|
|
17
|
+
|
|
18
|
+
let claude_dir = find_claude_project_dir(project_dir);
|
|
19
|
+
|
|
20
|
+
if let Some(dir) = claude_dir {
|
|
21
|
+
if let Some(latest) = find_latest_jsonl(&dir) {
|
|
22
|
+
return parse_session_transcript(&latest);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fallback: infer from git state
|
|
27
|
+
SessionInfo {
|
|
28
|
+
current_task: infer_task_from_git(project_dir),
|
|
29
|
+
decisions: Vec::new(),
|
|
30
|
+
last_error: None,
|
|
31
|
+
last_output: None,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn find_claude_project_dir(project_dir: &Path) -> Option<std::path::PathBuf> {
|
|
36
|
+
let home = std::env::var("HOME").ok()?;
|
|
37
|
+
let claude_projects = std::path::PathBuf::from(&home).join(".claude/projects");
|
|
38
|
+
if !claude_projects.exists() {
|
|
39
|
+
return None;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Claude encodes the project path: /Users/user/myproject -> -Users-user-myproject
|
|
43
|
+
let proj_str = project_dir.to_string_lossy().replace('/', "-");
|
|
44
|
+
let candidate = claude_projects.join(&proj_str);
|
|
45
|
+
if candidate.exists() {
|
|
46
|
+
return Some(candidate);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Try matching by suffix
|
|
50
|
+
if let Ok(entries) = std::fs::read_dir(&claude_projects) {
|
|
51
|
+
let dir_name = project_dir.file_name()?.to_string_lossy();
|
|
52
|
+
for entry in entries.flatten() {
|
|
53
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
54
|
+
if name.ends_with(&*dir_name) && entry.path().is_dir() {
|
|
55
|
+
return Some(entry.path());
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
None
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fn find_latest_jsonl(dir: &std::path::Path) -> Option<std::path::PathBuf> {
|
|
64
|
+
let mut newest: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
|
|
65
|
+
if let Ok(entries) = std::fs::read_dir(dir) {
|
|
66
|
+
for entry in entries.flatten() {
|
|
67
|
+
let path = entry.path();
|
|
68
|
+
if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
|
|
69
|
+
if let Ok(meta) = path.metadata() {
|
|
70
|
+
if let Ok(modified) = meta.modified() {
|
|
71
|
+
if newest.as_ref().map_or(true, |(_, t)| modified > *t) {
|
|
72
|
+
newest = Some((path, modified));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
newest.map(|(p, _)| p)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
83
|
+
let content = match std::fs::read_to_string(path) {
|
|
84
|
+
Ok(c) => c,
|
|
85
|
+
Err(_) => return default_session_info(),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let mut current_task = String::new();
|
|
89
|
+
let mut decisions = Vec::new();
|
|
90
|
+
let mut last_error = None;
|
|
91
|
+
let mut last_output = None;
|
|
92
|
+
|
|
93
|
+
// Read last 200 lines of the JSONL for efficiency
|
|
94
|
+
let lines: Vec<&str> = content.lines().collect();
|
|
95
|
+
let start = lines.len().saturating_sub(200);
|
|
96
|
+
|
|
97
|
+
for line in &lines[start..] {
|
|
98
|
+
let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
|
99
|
+
|
|
100
|
+
// Extract user messages for task context
|
|
101
|
+
if val.get("type").and_then(|v| v.as_str()) == Some("human") {
|
|
102
|
+
if let Some(msg) = val.get("message").and_then(|m| {
|
|
103
|
+
m.get("content").and_then(|c| {
|
|
104
|
+
if let Some(s) = c.as_str() {
|
|
105
|
+
Some(s.to_string())
|
|
106
|
+
} else if let Some(arr) = c.as_array() {
|
|
107
|
+
arr.iter()
|
|
108
|
+
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
|
|
109
|
+
.last()
|
|
110
|
+
.map(String::from)
|
|
111
|
+
} else {
|
|
112
|
+
None
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}) {
|
|
116
|
+
// Keep the last substantive user message as current task
|
|
117
|
+
if msg.len() > 10 && !msg.starts_with('/') {
|
|
118
|
+
current_task = if msg.len() > 500 {
|
|
119
|
+
format!("{}...", &msg[..500])
|
|
120
|
+
} else {
|
|
121
|
+
msg
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Extract assistant tool use for last output
|
|
128
|
+
if val.get("type").and_then(|v| v.as_str()) == Some("assistant") {
|
|
129
|
+
if let Some(content) = val.get("message").and_then(|m| m.get("content")) {
|
|
130
|
+
if let Some(arr) = content.as_array() {
|
|
131
|
+
for item in arr {
|
|
132
|
+
// Look for tool results
|
|
133
|
+
if item.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
|
|
134
|
+
if let Some(output) = item.get("content").and_then(|c| c.as_str()) {
|
|
135
|
+
// Check for errors
|
|
136
|
+
let lower = output.to_lowercase();
|
|
137
|
+
if lower.contains("error") || lower.contains("failed") || lower.contains("panic") {
|
|
138
|
+
last_error = Some(truncate(output, 500));
|
|
139
|
+
}
|
|
140
|
+
last_output = Some(truncate(output, 500));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Look for text content with decisions
|
|
144
|
+
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
|
145
|
+
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
|
146
|
+
// Extract lines that look like decisions
|
|
147
|
+
for line in text.lines() {
|
|
148
|
+
let trimmed = line.trim();
|
|
149
|
+
if (trimmed.starts_with("I'll use") ||
|
|
150
|
+
trimmed.starts_with("I chose") ||
|
|
151
|
+
trimmed.starts_with("Decision:") ||
|
|
152
|
+
trimmed.starts_with("Using ") ||
|
|
153
|
+
trimmed.starts_with("Approach:"))
|
|
154
|
+
&& trimmed.len() > 15
|
|
155
|
+
{
|
|
156
|
+
decisions.push(truncate(trimmed, 200));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if current_task.is_empty() {
|
|
168
|
+
current_task = "Could not determine current task from session transcript".into();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Deduplicate and limit decisions
|
|
172
|
+
decisions.dedup();
|
|
173
|
+
decisions.truncate(10);
|
|
174
|
+
|
|
175
|
+
SessionInfo {
|
|
176
|
+
current_task,
|
|
177
|
+
decisions,
|
|
178
|
+
last_error,
|
|
179
|
+
last_output,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fn infer_task_from_git(project_dir: &Path) -> String {
|
|
184
|
+
// Infer from recent commit messages
|
|
185
|
+
let output = std::process::Command::new("git")
|
|
186
|
+
.current_dir(project_dir)
|
|
187
|
+
.args(["log", "--oneline", "-1", "--no-decorate"])
|
|
188
|
+
.output();
|
|
189
|
+
|
|
190
|
+
if let Ok(out) = output {
|
|
191
|
+
if out.status.success() {
|
|
192
|
+
let msg = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
193
|
+
if !msg.is_empty() {
|
|
194
|
+
return format!("Recent work: {msg}");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
"Unknown — no session transcript or git history found".into()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fn truncate(s: &str, max: usize) -> String {
|
|
203
|
+
if s.len() <= max {
|
|
204
|
+
s.to_string()
|
|
205
|
+
} else {
|
|
206
|
+
format!("{}...", &s[..max])
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn default_session_info() -> SessionInfo {
|
|
211
|
+
SessionInfo {
|
|
212
|
+
current_task: "Could not read session transcript".into(),
|
|
213
|
+
decisions: Vec::new(),
|
|
214
|
+
last_error: None,
|
|
215
|
+
last_output: None,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//! Read TodoWrite state from Claude Code session.
|
|
2
|
+
|
|
3
|
+
use crate::TodoItem;
|
|
4
|
+
use std::path::Path;
|
|
5
|
+
|
|
6
|
+
/// Attempt to read the current todo list from the latest session transcript.
|
|
7
|
+
pub fn read_todos(project_dir: &Path) -> Vec<TodoItem> {
|
|
8
|
+
// Try to read from the latest session's JSONL
|
|
9
|
+
let _session = super::session::read_latest_session(project_dir);
|
|
10
|
+
|
|
11
|
+
// Parse from the session info — todos are embedded in tool_use calls
|
|
12
|
+
// For now, try to read from the last TodoWrite call in the transcript
|
|
13
|
+
let transcript_todos = read_todos_from_transcript(project_dir);
|
|
14
|
+
if !transcript_todos.is_empty() {
|
|
15
|
+
return transcript_todos;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Fallback: try to infer from git
|
|
19
|
+
Vec::new()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fn read_todos_from_transcript(project_dir: &Path) -> Vec<TodoItem> {
|
|
23
|
+
let home = std::env::var("HOME").unwrap_or_default();
|
|
24
|
+
let claude_projects = std::path::PathBuf::from(&home).join(".claude/projects");
|
|
25
|
+
if !claude_projects.exists() {
|
|
26
|
+
return Vec::new();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let proj_str = project_dir.to_string_lossy().replace('/', "-");
|
|
30
|
+
let dir = claude_projects.join(&proj_str);
|
|
31
|
+
if !dir.exists() {
|
|
32
|
+
// Try suffix match
|
|
33
|
+
let entries = std::fs::read_dir(&claude_projects).ok();
|
|
34
|
+
let dir_name = project_dir.file_name().map(|n| n.to_string_lossy().to_string());
|
|
35
|
+
let matched_dir = entries.and_then(|es| {
|
|
36
|
+
let name = dir_name?;
|
|
37
|
+
for e in es.flatten() {
|
|
38
|
+
let n = e.file_name().to_string_lossy().to_string();
|
|
39
|
+
if n.ends_with(&name) && e.path().is_dir() {
|
|
40
|
+
return Some(e.path());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
None
|
|
44
|
+
});
|
|
45
|
+
if matched_dir.is_none() {
|
|
46
|
+
return Vec::new();
|
|
47
|
+
}
|
|
48
|
+
return parse_todos_from_dir(&matched_dir.unwrap());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
parse_todos_from_dir(&dir)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fn parse_todos_from_dir(dir: &Path) -> Vec<TodoItem> {
|
|
55
|
+
// Find latest JSONL
|
|
56
|
+
let mut newest: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
|
|
57
|
+
if let Ok(entries) = std::fs::read_dir(dir) {
|
|
58
|
+
for entry in entries.flatten() {
|
|
59
|
+
let path = entry.path();
|
|
60
|
+
if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
|
|
61
|
+
if let Ok(meta) = path.metadata() {
|
|
62
|
+
if let Ok(modified) = meta.modified() {
|
|
63
|
+
if newest.as_ref().map_or(true, |(_, t)| modified > *t) {
|
|
64
|
+
newest = Some((path, modified));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let Some((path, _)) = newest else { return Vec::new() };
|
|
73
|
+
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
|
74
|
+
|
|
75
|
+
// Scan for the last TodoWrite tool_use call
|
|
76
|
+
let mut last_todos: Vec<TodoItem> = Vec::new();
|
|
77
|
+
for line in content.lines().rev() {
|
|
78
|
+
let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
|
79
|
+
|
|
80
|
+
// Look for tool_use with name "TodoWrite"
|
|
81
|
+
if let Some(content) = val.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array()) {
|
|
82
|
+
for item in content {
|
|
83
|
+
if item.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
|
84
|
+
if item.get("name").and_then(|n| n.as_str()) == Some("TodoWrite") {
|
|
85
|
+
if let Some(input) = item.get("input") {
|
|
86
|
+
if let Some(todos) = input.get("todos").and_then(|t| t.as_array()) {
|
|
87
|
+
last_todos = todos.iter().filter_map(|t| {
|
|
88
|
+
let content = t.get("content")?.as_str()?.to_string();
|
|
89
|
+
let status = t.get("status")?.as_str()?.to_string();
|
|
90
|
+
Some(TodoItem { content, status })
|
|
91
|
+
}).collect();
|
|
92
|
+
if !last_todos.is_empty() {
|
|
93
|
+
return last_todos;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
last_todos
|
|
104
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//! Rate limit detection — identifies when Claude Code has hit its limit.
|
|
2
|
+
|
|
3
|
+
use anyhow::Result;
|
|
4
|
+
|
|
5
|
+
/// Signals from tool output that suggest a rate limit.
|
|
6
|
+
static RATE_LIMIT_SIGNALS: &[&str] = &[
|
|
7
|
+
"rate limit",
|
|
8
|
+
"rate_limit",
|
|
9
|
+
"quota exceeded",
|
|
10
|
+
"too many requests",
|
|
11
|
+
"429",
|
|
12
|
+
"capacity",
|
|
13
|
+
"overloaded",
|
|
14
|
+
"try again later",
|
|
15
|
+
"usage limit",
|
|
16
|
+
"limit reached",
|
|
17
|
+
"context window full",
|
|
18
|
+
"maximum context",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/// Check if a tool output string contains rate limit signals.
|
|
22
|
+
pub fn is_rate_limited(text: &str) -> bool {
|
|
23
|
+
let lower = text.to_lowercase();
|
|
24
|
+
RATE_LIMIT_SIGNALS.iter().any(|sig| lower.contains(sig))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Hook handler — reads PostToolUse JSON, checks for rate limit,
|
|
28
|
+
/// triggers handoff if detected.
|
|
29
|
+
pub fn check_hook_output(raw: &str) -> Option<RateLimitDetection> {
|
|
30
|
+
let val: serde_json::Value = serde_json::from_str(raw).ok()?;
|
|
31
|
+
|
|
32
|
+
let tool_output = val.get("tool_output").and_then(|v| v.as_str()).unwrap_or("");
|
|
33
|
+
let tool_name = val.get("tool_name").and_then(|v| v.as_str()).unwrap_or("");
|
|
34
|
+
|
|
35
|
+
if is_rate_limited(tool_output) {
|
|
36
|
+
return Some(RateLimitDetection {
|
|
37
|
+
tool_name: tool_name.to_string(),
|
|
38
|
+
signal: extract_signal(tool_output),
|
|
39
|
+
full_output: tool_output.to_string(),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
None
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Watch the Claude Code process for exit with rate limit.
|
|
47
|
+
pub fn watch_claude_process() -> Result<WatchResult> {
|
|
48
|
+
// Check if Claude Code is running
|
|
49
|
+
let output = std::process::Command::new("pgrep")
|
|
50
|
+
.args(["-f", "claude"])
|
|
51
|
+
.output()?;
|
|
52
|
+
|
|
53
|
+
let running = output.status.success();
|
|
54
|
+
|
|
55
|
+
Ok(WatchResult {
|
|
56
|
+
claude_running: running,
|
|
57
|
+
rate_limited: false, // Can't detect from outside without hook
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub struct RateLimitDetection {
|
|
62
|
+
pub tool_name: String,
|
|
63
|
+
pub signal: String,
|
|
64
|
+
pub full_output: String,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pub struct WatchResult {
|
|
68
|
+
pub claude_running: bool,
|
|
69
|
+
pub rate_limited: bool,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn extract_signal(text: &str) -> String {
|
|
73
|
+
let lower = text.to_lowercase();
|
|
74
|
+
for sig in RATE_LIMIT_SIGNALS {
|
|
75
|
+
if lower.contains(sig) {
|
|
76
|
+
return sig.to_string();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
"unknown".into()
|
|
80
|
+
}
|