@masyv/relay 0.3.0 → 0.4.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.
@@ -0,0 +1,75 @@
1
+ //! Aider agent adapter — launches the aider TUI interactively.
2
+ //! https://github.com/paul-gauthier/aider
3
+
4
+ use super::Agent;
5
+ use crate::{AgentStatus, HandoffResult};
6
+ use anyhow::Result;
7
+ use std::process::Command;
8
+
9
+ pub struct AiderAgent {
10
+ model: String,
11
+ }
12
+
13
+ impl AiderAgent {
14
+ pub fn new(model: Option<&str>) -> Self {
15
+ Self {
16
+ model: model.unwrap_or("sonnet").to_string(),
17
+ }
18
+ }
19
+
20
+ fn find_binary() -> Option<String> {
21
+ let output = Command::new("which").arg("aider").output().ok()?;
22
+ if output.status.success() {
23
+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
24
+ } else {
25
+ None
26
+ }
27
+ }
28
+ }
29
+
30
+ impl Agent for AiderAgent {
31
+ fn name(&self) -> &str { "aider" }
32
+
33
+ fn check_available(&self) -> AgentStatus {
34
+ match Self::find_binary() {
35
+ Some(path) => AgentStatus {
36
+ name: "aider".into(),
37
+ available: true,
38
+ reason: format!("Found at {path}"),
39
+ version: Command::new(&path).arg("--version").output().ok()
40
+ .filter(|o| o.status.success())
41
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()),
42
+ },
43
+ None => AgentStatus {
44
+ name: "aider".into(),
45
+ available: false,
46
+ reason: "Not found. Install: pip install aider-chat".into(),
47
+ version: None,
48
+ },
49
+ }
50
+ }
51
+
52
+ fn execute(&self, handoff_prompt: &str, project_dir: &str) -> Result<HandoffResult> {
53
+ let binary = Self::find_binary().unwrap_or("aider".into());
54
+ let tmp = std::env::temp_dir().join("relay_handoff.md");
55
+ std::fs::write(&tmp, handoff_prompt)?;
56
+
57
+ let status = Command::new(&binary)
58
+ .current_dir(project_dir)
59
+ .arg("--model")
60
+ .arg(&self.model)
61
+ .arg("--message")
62
+ .arg(handoff_prompt)
63
+ .stdin(std::process::Stdio::inherit())
64
+ .stdout(std::process::Stdio::inherit())
65
+ .stderr(std::process::Stdio::inherit())
66
+ .status()?;
67
+
68
+ Ok(HandoffResult {
69
+ agent: "aider".into(),
70
+ success: status.success(),
71
+ message: format!("Aider ({}) session ended", self.model),
72
+ handoff_file: Some(tmp.to_string_lossy().to_string()),
73
+ })
74
+ }
75
+ }
@@ -0,0 +1,68 @@
1
+ //! Claude CLI agent — starts a new Claude Code session with full context.
2
+ //! Useful when the rate limit resets or you have a second subscription.
3
+
4
+ use super::Agent;
5
+ use crate::{AgentStatus, HandoffResult};
6
+ use anyhow::Result;
7
+ use std::process::Command;
8
+
9
+ pub struct ClaudeAgent;
10
+
11
+ impl ClaudeAgent {
12
+ pub fn new() -> Self { Self }
13
+
14
+ fn find_binary() -> Option<String> {
15
+ let output = Command::new("which").arg("claude").output().ok()?;
16
+ if output.status.success() {
17
+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
18
+ } else {
19
+ None
20
+ }
21
+ }
22
+ }
23
+
24
+ impl Agent for ClaudeAgent {
25
+ fn name(&self) -> &str { "claude" }
26
+
27
+ fn check_available(&self) -> AgentStatus {
28
+ match Self::find_binary() {
29
+ Some(path) => AgentStatus {
30
+ name: "claude".into(),
31
+ available: true,
32
+ reason: format!("Found at {path}"),
33
+ version: Command::new(&path).arg("--version").output().ok()
34
+ .filter(|o| o.status.success())
35
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()),
36
+ },
37
+ None => AgentStatus {
38
+ name: "claude".into(),
39
+ available: false,
40
+ reason: "Not found. Install: npm install -g @anthropic-ai/claude-code".into(),
41
+ version: None,
42
+ },
43
+ }
44
+ }
45
+
46
+ fn execute(&self, handoff_prompt: &str, project_dir: &str) -> Result<HandoffResult> {
47
+ let binary = Self::find_binary().unwrap_or("claude".into());
48
+ let tmp = std::env::temp_dir().join("relay_handoff.md");
49
+ std::fs::write(&tmp, handoff_prompt)?;
50
+
51
+ // Launch Claude interactively with the handoff as initial prompt
52
+ let status = Command::new(&binary)
53
+ .current_dir(project_dir)
54
+ .arg("--resume")
55
+ .arg(handoff_prompt)
56
+ .stdin(std::process::Stdio::inherit())
57
+ .stdout(std::process::Stdio::inherit())
58
+ .stderr(std::process::Stdio::inherit())
59
+ .status()?;
60
+
61
+ Ok(HandoffResult {
62
+ agent: "claude".into(),
63
+ success: status.success(),
64
+ message: "Claude session ended".into(),
65
+ handoff_file: Some(tmp.to_string_lossy().to_string()),
66
+ })
67
+ }
68
+ }
@@ -0,0 +1,63 @@
1
+ //! GitHub Copilot CLI agent adapter.
2
+
3
+ use super::Agent;
4
+ use crate::{AgentStatus, HandoffResult};
5
+ use anyhow::Result;
6
+ use std::process::Command;
7
+
8
+ pub struct CopilotAgent;
9
+
10
+ impl CopilotAgent {
11
+ pub fn new() -> Self { Self }
12
+
13
+ fn find_binary() -> Option<String> {
14
+ let output = Command::new("which").arg("copilot").output().ok()?;
15
+ if output.status.success() {
16
+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
17
+ } else {
18
+ None
19
+ }
20
+ }
21
+ }
22
+
23
+ impl Agent for CopilotAgent {
24
+ fn name(&self) -> &str { "copilot" }
25
+
26
+ fn check_available(&self) -> AgentStatus {
27
+ match Self::find_binary() {
28
+ Some(path) => AgentStatus {
29
+ name: "copilot".into(),
30
+ available: true,
31
+ reason: format!("Found at {path}"),
32
+ version: None, // copilot --version hangs, skip it
33
+ },
34
+ None => AgentStatus {
35
+ name: "copilot".into(),
36
+ available: false,
37
+ reason: "Not found. Install: gh extension install github/gh-copilot".into(),
38
+ version: None,
39
+ },
40
+ }
41
+ }
42
+
43
+ fn execute(&self, handoff_prompt: &str, project_dir: &str) -> Result<HandoffResult> {
44
+ let binary = Self::find_binary().unwrap_or("copilot".into());
45
+ let tmp = std::env::temp_dir().join("relay_handoff.md");
46
+ std::fs::write(&tmp, handoff_prompt)?;
47
+
48
+ let status = Command::new(&binary)
49
+ .current_dir(project_dir)
50
+ .arg(handoff_prompt)
51
+ .stdin(std::process::Stdio::inherit())
52
+ .stdout(std::process::Stdio::inherit())
53
+ .stderr(std::process::Stdio::inherit())
54
+ .status()?;
55
+
56
+ Ok(HandoffResult {
57
+ agent: "copilot".into(),
58
+ success: status.success(),
59
+ message: "Copilot session ended".into(),
60
+ handoff_file: Some(tmp.to_string_lossy().to_string()),
61
+ })
62
+ }
63
+ }
@@ -1,7 +1,11 @@
1
+ pub mod aider;
2
+ pub mod claude;
1
3
  pub mod codex;
4
+ pub mod copilot;
2
5
  pub mod gemini;
3
6
  pub mod ollama;
4
7
  pub mod openai;
8
+ pub mod opencode;
5
9
 
6
10
  use crate::{AgentStatus, Config, HandoffResult};
7
11
  use anyhow::Result;
@@ -18,10 +22,14 @@ pub fn get_agents(config: &Config) -> Vec<Box<dyn Agent>> {
18
22
  let mut agents: Vec<Box<dyn Agent>> = Vec::new();
19
23
  for name in &config.general.priority {
20
24
  match name.as_str() {
21
- "codex" => agents.push(Box::new(codex::CodexAgent::new(&config.agents.codex))),
22
- "gemini" => agents.push(Box::new(gemini::GeminiAgent::new(&config.agents.gemini))),
23
- "ollama" => agents.push(Box::new(ollama::OllamaAgent::new(&config.agents.ollama))),
24
- "openai" => agents.push(Box::new(openai::OpenAIAgent::new(&config.agents.openai))),
25
+ "codex" => agents.push(Box::new(codex::CodexAgent::new(&config.agents.codex))),
26
+ "gemini" => agents.push(Box::new(gemini::GeminiAgent::new(&config.agents.gemini))),
27
+ "ollama" => agents.push(Box::new(ollama::OllamaAgent::new(&config.agents.ollama))),
28
+ "openai" => agents.push(Box::new(openai::OpenAIAgent::new(&config.agents.openai))),
29
+ "aider" => agents.push(Box::new(aider::AiderAgent::new(None))),
30
+ "claude" => agents.push(Box::new(claude::ClaudeAgent::new())),
31
+ "copilot" => agents.push(Box::new(copilot::CopilotAgent::new())),
32
+ "opencode" => agents.push(Box::new(opencode::OpenCodeAgent::new())),
25
33
  _ => {} // unknown agent, skip
26
34
  }
27
35
  }
@@ -3,6 +3,7 @@
3
3
  use super::Agent;
4
4
  use crate::{AgentStatus, HandoffResult, OllamaConfig};
5
5
  use anyhow::Result;
6
+ use std::process::Command;
6
7
 
7
8
  pub struct OllamaAgent {
8
9
  url: String,
@@ -22,43 +23,24 @@ impl Agent for OllamaAgent {
22
23
  fn name(&self) -> &str { "ollama" }
23
24
 
24
25
  fn check_available(&self) -> AgentStatus {
25
- // Ping Ollama's API
26
+ // Use curl with a 2s timeout — avoids ureq hanging on refused connections
26
27
  let tag_url = format!("{}/api/tags", self.url);
27
- match ureq::get(&tag_url).call() {
28
- Ok(resp) => {
29
- let body: serde_json::Value = resp.into_json().unwrap_or_default();
30
- let models = body.get("models")
31
- .and_then(|m| m.as_array())
32
- .map(|a| a.len())
33
- .unwrap_or(0);
28
+ let output = Command::new("curl")
29
+ .args(["--silent", "--max-time", "2", &tag_url])
30
+ .output();
34
31
 
35
- // Check if our target model is available
36
- let has_model = body.get("models")
37
- .and_then(|m| m.as_array())
38
- .map(|arr| arr.iter().any(|m| {
39
- m.get("name").and_then(|n| n.as_str())
40
- .map(|n| n.starts_with(&self.model))
41
- .unwrap_or(false)
42
- }))
43
- .unwrap_or(false);
44
-
45
- if has_model {
46
- AgentStatus {
47
- name: "ollama".into(),
48
- available: true,
49
- reason: format!("Running at {}, {} models, '{}' available", self.url, models, self.model),
50
- version: Some(self.model.clone()),
51
- }
52
- } else {
53
- AgentStatus {
54
- name: "ollama".into(),
55
- available: true,
56
- reason: format!("Running but model '{}' not pulled. {} models available", self.model, models),
57
- version: None,
58
- }
32
+ match output {
33
+ Ok(o) if o.status.success() => {
34
+ let body: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_default();
35
+ let models = body.get("models").and_then(|m| m.as_array()).map(|a| a.len()).unwrap_or(0);
36
+ AgentStatus {
37
+ name: "ollama".into(),
38
+ available: true,
39
+ reason: format!("Running at {}, {} models available", self.url, models),
40
+ version: Some(self.model.clone()),
59
41
  }
60
42
  }
61
- Err(_) => AgentStatus {
43
+ _ => AgentStatus {
62
44
  name: "ollama".into(),
63
45
  available: false,
64
46
  reason: format!("Not reachable at {}", self.url),
@@ -69,7 +51,6 @@ impl Agent for OllamaAgent {
69
51
 
70
52
  fn execute(&self, handoff_prompt: &str, _project_dir: &str) -> Result<HandoffResult> {
71
53
  let url = format!("{}/api/generate", self.url);
72
-
73
54
  let body = serde_json::json!({
74
55
  "model": self.model,
75
56
  "prompt": handoff_prompt,
@@ -81,11 +62,7 @@ impl Agent for OllamaAgent {
81
62
  .send_json(&body)?;
82
63
 
83
64
  let resp_json: serde_json::Value = resp.into_json()?;
84
- let text = resp_json
85
- .get("response")
86
- .and_then(|r| r.as_str())
87
- .unwrap_or("(no response)");
88
-
65
+ let text = resp_json.get("response").and_then(|r| r.as_str()).unwrap_or("(no response)");
89
66
  println!("{text}");
90
67
 
91
68
  Ok(HandoffResult {
@@ -0,0 +1,66 @@
1
+ //! OpenCode agent adapter — Go-based coding agent.
2
+ //! https://github.com/opencode-ai/opencode
3
+
4
+ use super::Agent;
5
+ use crate::{AgentStatus, HandoffResult};
6
+ use anyhow::Result;
7
+ use std::process::Command;
8
+
9
+ pub struct OpenCodeAgent;
10
+
11
+ impl OpenCodeAgent {
12
+ pub fn new() -> Self { Self }
13
+
14
+ fn find_binary() -> Option<String> {
15
+ let output = Command::new("which").arg("opencode").output().ok()?;
16
+ if output.status.success() {
17
+ Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
18
+ } else {
19
+ None
20
+ }
21
+ }
22
+ }
23
+
24
+ impl Agent for OpenCodeAgent {
25
+ fn name(&self) -> &str { "opencode" }
26
+
27
+ fn check_available(&self) -> AgentStatus {
28
+ match Self::find_binary() {
29
+ Some(path) => AgentStatus {
30
+ name: "opencode".into(),
31
+ available: true,
32
+ reason: format!("Found at {path}"),
33
+ version: Command::new(&path).arg("--version").output().ok()
34
+ .filter(|o| o.status.success())
35
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()),
36
+ },
37
+ None => AgentStatus {
38
+ name: "opencode".into(),
39
+ available: false,
40
+ reason: "Not found. Install: go install github.com/opencode-ai/opencode@latest".into(),
41
+ version: None,
42
+ },
43
+ }
44
+ }
45
+
46
+ fn execute(&self, handoff_prompt: &str, project_dir: &str) -> Result<HandoffResult> {
47
+ let binary = Self::find_binary().unwrap_or("opencode".into());
48
+ let tmp = std::env::temp_dir().join("relay_handoff.md");
49
+ std::fs::write(&tmp, handoff_prompt)?;
50
+
51
+ let status = Command::new(&binary)
52
+ .current_dir(project_dir)
53
+ .arg(handoff_prompt)
54
+ .stdin(std::process::Stdio::inherit())
55
+ .stdout(std::process::Stdio::inherit())
56
+ .stderr(std::process::Stdio::inherit())
57
+ .status()?;
58
+
59
+ Ok(HandoffResult {
60
+ agent: "opencode".into(),
61
+ success: status.success(),
62
+ message: "OpenCode session ended".into(),
63
+ handoff_file: Some(tmp.to_string_lossy().to_string()),
64
+ })
65
+ }
66
+ }
@@ -117,19 +117,26 @@ pub fn build_handoff(
117
117
  }
118
118
 
119
119
  // ── Instructions for agent ─────────────────────────────────
120
+ let deadline_note = if snapshot.deadline.is_some() {
121
+ "\nIMPORTANT: There is a DEADLINE. If the user asks you to continue, prioritise the current task."
122
+ } else { "" };
123
+
120
124
  let instructions = format!(
121
125
  "## INSTRUCTIONS\n\n\
122
- You are continuing work that was started in a Claude Code session.\n\
123
- The session was interrupted by a rate limit.\n\
124
- Pick up EXACTLY where it left off. Do NOT re-explain context.\n\
125
- The user is waiting — be efficient and direct.\n\
126
+ You have been given the full context from a Claude Code session.\n\
127
+ The context above shows what was being worked on, what decisions were made,\n\
128
+ what files were changed, and what the last state was.\n\
129
+ \n\
130
+ DO NOT immediately start working on anything.\n\
131
+ Instead, briefly confirm you have the context by saying something like:\n\
132
+ \"Context restored from your Claude session. I can see you were working on [brief summary]. What would you like me to do?\"\n\
133
+ \n\
134
+ Then WAIT for the user to tell you what to do next.\n\
126
135
  \n\
127
- Working directory: {}\n\
136
+ Working directory: {}\
128
137
  {}",
129
138
  snapshot.project_dir,
130
- if snapshot.deadline.is_some() {
131
- "THERE IS A DEADLINE. Prioritise completing the current task."
132
- } else { "" }
139
+ deadline_note
133
140
  );
134
141
  sections.push(instructions);
135
142
 
package/core/src/lib.rs CHANGED
@@ -43,7 +43,11 @@ impl Default for GeneralConfig {
43
43
  fn default_priority() -> Vec<String> {
44
44
  vec![
45
45
  "codex".into(),
46
+ "claude".into(),
47
+ "aider".into(),
46
48
  "gemini".into(),
49
+ "copilot".into(),
50
+ "opencode".into(),
47
51
  "ollama".into(),
48
52
  "openai".into(),
49
53
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masyv/relay",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Relay — When Claude's rate limit hits, another agent picks up exactly where you left off. Captures session state and hands off to Codex, Gemini, Ollama, or GPT-4.",
5
5
  "scripts": {
6
6
  "build": "./scripts/build.sh",