@masyv/relay 0.3.0 → 0.5.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/core/Cargo.toml +4 -1
- package/core/src/agents/aider.rs +75 -0
- package/core/src/agents/claude.rs +68 -0
- package/core/src/agents/copilot.rs +63 -0
- package/core/src/agents/mod.rs +12 -4
- package/core/src/agents/ollama.rs +16 -39
- package/core/src/agents/opencode.rs +66 -0
- package/core/src/handoff/mod.rs +15 -8
- package/core/src/lib.rs +5 -0
- package/core/src/main.rs +117 -223
- package/core/src/tui.rs +324 -0
- package/package.json +1 -1
package/core/Cargo.toml
CHANGED
|
@@ -42,8 +42,11 @@ blake3 = "1"
|
|
|
42
42
|
# HTTP client (for Ollama, OpenAI, Gemini APIs)
|
|
43
43
|
ureq = { version = "2", features = ["json"] }
|
|
44
44
|
|
|
45
|
-
# Terminal
|
|
45
|
+
# Terminal UI
|
|
46
46
|
colored = "2"
|
|
47
|
+
indicatif = "0.17"
|
|
48
|
+
dialoguer = { version = "0.11", features = ["fuzzy-select"] }
|
|
49
|
+
console = "0.15"
|
|
47
50
|
|
|
48
51
|
[profile.release]
|
|
49
52
|
opt-level = 3
|
|
@@ -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
|
+
}
|
package/core/src/agents/mod.rs
CHANGED
|
@@ -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"
|
|
22
|
-
"gemini"
|
|
23
|
-
"ollama"
|
|
24
|
-
"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
|
-
//
|
|
26
|
+
// Use curl with a 2s timeout — avoids ureq hanging on refused connections
|
|
26
27
|
let tag_url = format!("{}/api/tags", self.url);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
})
|
|
43
|
-
.
|
|
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
|
-
|
|
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
|
+
}
|
package/core/src/handoff/mod.rs
CHANGED
|
@@ -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
|
|
123
|
-
The
|
|
124
|
-
|
|
125
|
-
|
|
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: {}\
|
|
136
|
+
Working directory: {}\
|
|
128
137
|
{}",
|
|
129
138
|
snapshot.project_dir,
|
|
130
|
-
|
|
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
|
@@ -2,6 +2,7 @@ pub mod agents;
|
|
|
2
2
|
pub mod capture;
|
|
3
3
|
pub mod detect;
|
|
4
4
|
pub mod handoff;
|
|
5
|
+
pub mod tui;
|
|
5
6
|
|
|
6
7
|
use serde::{Deserialize, Serialize};
|
|
7
8
|
use std::path::PathBuf;
|
|
@@ -43,7 +44,11 @@ impl Default for GeneralConfig {
|
|
|
43
44
|
fn default_priority() -> Vec<String> {
|
|
44
45
|
vec![
|
|
45
46
|
"codex".into(),
|
|
47
|
+
"claude".into(),
|
|
48
|
+
"aider".into(),
|
|
46
49
|
"gemini".into(),
|
|
50
|
+
"copilot".into(),
|
|
51
|
+
"opencode".into(),
|
|
47
52
|
"ollama".into(),
|
|
48
53
|
"openai".into(),
|
|
49
54
|
]
|
package/core/src/main.rs
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
use anyhow::Result;
|
|
2
2
|
use clap::{Parser, Subcommand};
|
|
3
|
-
use colored::Colorize;
|
|
4
3
|
use std::path::PathBuf;
|
|
5
4
|
|
|
6
|
-
use relay::{agents, capture, handoff, Config};
|
|
5
|
+
use relay::{agents, capture, handoff, tui, Config};
|
|
7
6
|
|
|
8
7
|
#[derive(Parser)]
|
|
9
8
|
#[command(
|
|
10
9
|
name = "relay",
|
|
11
10
|
about = "Relay — When Claude's rate limit hits, another agent picks up where you left off.",
|
|
12
|
-
long_about = "Captures your Claude Code session state (task, todos, git diff, decisions,\nerrors) and hands it off to Codex, Gemini, Ollama, or GPT-4 — so your\nwork never stops.",
|
|
13
11
|
version
|
|
14
12
|
)]
|
|
15
13
|
struct Cli {
|
|
16
14
|
#[command(subcommand)]
|
|
17
15
|
command: Commands,
|
|
18
16
|
|
|
19
|
-
/// Output as JSON
|
|
17
|
+
/// Output as JSON (no TUI)
|
|
20
18
|
#[arg(long, global = true)]
|
|
21
19
|
json: bool,
|
|
22
20
|
|
|
@@ -31,17 +29,17 @@ struct Cli {
|
|
|
31
29
|
|
|
32
30
|
#[derive(Subcommand)]
|
|
33
31
|
enum Commands {
|
|
34
|
-
/// Hand off current session to a fallback agent
|
|
32
|
+
/// Hand off current session to a fallback agent
|
|
35
33
|
Handoff {
|
|
36
|
-
///
|
|
34
|
+
/// Target agent (codex, claude, aider, gemini, copilot, opencode, ollama, openai)
|
|
37
35
|
#[arg(long)]
|
|
38
36
|
to: Option<String>,
|
|
39
37
|
|
|
40
|
-
/// Set deadline urgency (e.g. "7pm", "
|
|
38
|
+
/// Set deadline urgency (e.g. "7pm", "30min")
|
|
41
39
|
#[arg(long)]
|
|
42
40
|
deadline: Option<String>,
|
|
43
41
|
|
|
44
|
-
///
|
|
42
|
+
/// Just print the handoff — don't launch agent
|
|
45
43
|
#[arg(long)]
|
|
46
44
|
dry_run: bool,
|
|
47
45
|
|
|
@@ -54,18 +52,17 @@ enum Commands {
|
|
|
54
52
|
include: String,
|
|
55
53
|
},
|
|
56
54
|
|
|
57
|
-
/// Show current session snapshot
|
|
55
|
+
/// Show current session snapshot
|
|
58
56
|
Status,
|
|
59
57
|
|
|
60
|
-
/// List configured agents and
|
|
58
|
+
/// List configured agents and availability
|
|
61
59
|
Agents,
|
|
62
60
|
|
|
63
|
-
/// Generate default config
|
|
61
|
+
/// Generate default config at ~/.relay/config.toml
|
|
64
62
|
Init,
|
|
65
63
|
|
|
66
|
-
/// PostToolUse hook
|
|
64
|
+
/// PostToolUse hook (auto-detect rate limits)
|
|
67
65
|
Hook {
|
|
68
|
-
/// Session ID
|
|
69
66
|
#[arg(long, default_value = "unknown")]
|
|
70
67
|
session: String,
|
|
71
68
|
},
|
|
@@ -92,286 +89,183 @@ fn main() -> Result<()> {
|
|
|
92
89
|
});
|
|
93
90
|
|
|
94
91
|
match cli.command {
|
|
92
|
+
// ═══════════════════════════════════════════════════════════════
|
|
93
|
+
// HANDOFF
|
|
94
|
+
// ═══════════════════════════════════════════════════════════════
|
|
95
95
|
Commands::Handoff { to, deadline, dry_run, turns, include } => {
|
|
96
|
-
|
|
96
|
+
if !cli.json {
|
|
97
|
+
tui::print_banner();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Step 1: Capture
|
|
101
|
+
let sp = if !cli.json { Some(tui::step(1, 3, "Capturing session state...")) } else { None };
|
|
97
102
|
|
|
98
|
-
// Set conversation turn limit before capture
|
|
99
103
|
relay::capture::session::MAX_CONVERSATION_TURNS
|
|
100
104
|
.store(turns, std::sync::atomic::Ordering::Relaxed);
|
|
101
105
|
|
|
102
|
-
let mut snapshot = capture::capture_snapshot(
|
|
103
|
-
&project_dir,
|
|
104
|
-
deadline.as_deref(),
|
|
105
|
-
)?;
|
|
106
|
+
let mut snapshot = capture::capture_snapshot(&project_dir, deadline.as_deref())?;
|
|
106
107
|
|
|
107
|
-
//
|
|
108
|
+
// Apply include filter
|
|
108
109
|
let includes: Vec<&str> = include.split(',').map(|s| s.trim()).collect();
|
|
109
110
|
if !includes.contains(&"all") {
|
|
110
|
-
if !includes.contains(&"conversation") {
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
if !includes.contains(&"git") {
|
|
114
|
-
snapshot.git_state = None;
|
|
115
|
-
snapshot.recent_files.clear();
|
|
116
|
-
}
|
|
117
|
-
if !includes.contains(&"todos") {
|
|
118
|
-
snapshot.todos.clear();
|
|
119
|
-
}
|
|
111
|
+
if !includes.contains(&"conversation") { snapshot.conversation.clear(); }
|
|
112
|
+
if !includes.contains(&"git") { snapshot.git_state = None; snapshot.recent_files.clear(); }
|
|
113
|
+
if !includes.contains(&"todos") { snapshot.todos.clear(); }
|
|
120
114
|
}
|
|
121
115
|
|
|
122
|
-
let
|
|
116
|
+
if let Some(sp) = sp { sp.finish_with_message("Session captured"); }
|
|
117
|
+
|
|
118
|
+
// Step 2: Build handoff
|
|
119
|
+
let sp = if !cli.json { Some(tui::step(2, 3, "Building handoff package...")) } else { None };
|
|
120
|
+
|
|
121
|
+
// Resolve target agent
|
|
122
|
+
let target_name = if let Some(ref name) = to {
|
|
123
|
+
name.clone()
|
|
124
|
+
} else if !cli.json && !dry_run {
|
|
125
|
+
// Interactive agent selection
|
|
126
|
+
if let Some(sp) = sp.as_ref() { sp.finish_with_message("Handoff built"); }
|
|
127
|
+
|
|
128
|
+
let statuses = agents::check_all_agents(&config);
|
|
129
|
+
let agent_list: Vec<(String, bool, String)> = statuses
|
|
130
|
+
.iter()
|
|
131
|
+
.map(|s| (s.name.clone(), s.available, s.reason.clone()))
|
|
132
|
+
.collect();
|
|
133
|
+
|
|
134
|
+
match tui::select_agent(&agent_list) {
|
|
135
|
+
Some(name) => name,
|
|
136
|
+
None => {
|
|
137
|
+
eprintln!(" No agent selected.");
|
|
138
|
+
return Ok(());
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
"auto".into()
|
|
143
|
+
};
|
|
144
|
+
|
|
123
145
|
let handoff_text = handoff::build_handoff(
|
|
124
|
-
&snapshot,
|
|
125
|
-
target,
|
|
126
|
-
config.general.max_context_tokens,
|
|
146
|
+
&snapshot, &target_name, config.general.max_context_tokens,
|
|
127
147
|
)?;
|
|
128
|
-
|
|
129
|
-
// Save handoff file for reference
|
|
130
148
|
let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
|
|
131
149
|
|
|
132
|
-
if
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
150
|
+
if let Some(sp) = sp { sp.finish_with_message("Handoff built"); }
|
|
151
|
+
|
|
152
|
+
// JSON / dry-run output
|
|
153
|
+
if cli.json {
|
|
154
|
+
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
|
|
155
|
+
"snapshot": snapshot,
|
|
156
|
+
"handoff_text": handoff_text,
|
|
157
|
+
"handoff_file": handoff_path.to_string_lossy(),
|
|
158
|
+
"target_agent": target_name,
|
|
159
|
+
}))?);
|
|
160
|
+
return Ok(());
|
|
161
|
+
}
|
|
162
|
+
if dry_run {
|
|
163
|
+
println!("{handoff_text}");
|
|
164
|
+
eprintln!();
|
|
165
|
+
eprintln!(" 📄 Saved: {}", handoff_path.display());
|
|
146
166
|
return Ok(());
|
|
147
167
|
}
|
|
148
168
|
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
// Step 3: Launch agent
|
|
170
|
+
let sp = tui::step(3, 3, &format!("Launching {}...", target_name));
|
|
151
171
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
agents::handoff_to_named(&config, agent_name, &handoff_text, &project_dir.to_string_lossy())
|
|
172
|
+
let result = if to.is_some() {
|
|
173
|
+
agents::handoff_to_named(&config, &target_name, &handoff_text, &project_dir.to_string_lossy())
|
|
155
174
|
} else {
|
|
156
175
|
agents::handoff_to_first_available(&config, &handoff_text, &project_dir.to_string_lossy())
|
|
157
176
|
}?;
|
|
158
177
|
|
|
178
|
+
sp.finish_with_message(if result.success {
|
|
179
|
+
format!("{} launched", target_name)
|
|
180
|
+
} else {
|
|
181
|
+
"Failed".into()
|
|
182
|
+
});
|
|
183
|
+
|
|
159
184
|
if result.success {
|
|
160
|
-
|
|
161
|
-
eprintln!(" {}", result.message);
|
|
185
|
+
tui::print_handoff_success(&result.agent, &handoff_path.to_string_lossy());
|
|
162
186
|
} else {
|
|
163
|
-
|
|
164
|
-
eprintln!();
|
|
165
|
-
eprintln!("💡 The handoff context was saved to:");
|
|
166
|
-
eprintln!(" {}", handoff_path.display());
|
|
167
|
-
eprintln!(" You can copy-paste it into any AI assistant manually.");
|
|
187
|
+
tui::print_handoff_fail(&result.message, &handoff_path.to_string_lossy());
|
|
168
188
|
}
|
|
169
189
|
}
|
|
170
190
|
|
|
191
|
+
// ═══════════════════════════════════════════════════════════════
|
|
192
|
+
// STATUS
|
|
193
|
+
// ═══════════════════════════════════════════════════════════════
|
|
171
194
|
Commands::Status => {
|
|
195
|
+
let sp = if !cli.json { Some(tui::spinner("Reading session state...")) } else { None };
|
|
172
196
|
let snapshot = capture::capture_snapshot(&project_dir, None)?;
|
|
197
|
+
if let Some(sp) = sp { sp.finish_and_clear(); }
|
|
173
198
|
|
|
174
199
|
if cli.json {
|
|
175
200
|
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
println!("{}", "═══ Relay Session Snapshot ═══".bold());
|
|
180
|
-
println!();
|
|
181
|
-
println!("{}: {}", "Project".bold(), snapshot.project_dir);
|
|
182
|
-
println!("{}: {}", "Captured".bold(), snapshot.timestamp);
|
|
183
|
-
println!();
|
|
184
|
-
|
|
185
|
-
println!("{}", "── Current Task ──".cyan());
|
|
186
|
-
println!(" {}", snapshot.current_task);
|
|
187
|
-
println!();
|
|
188
|
-
|
|
189
|
-
if !snapshot.todos.is_empty() {
|
|
190
|
-
println!("{}", "── Todos ──".cyan());
|
|
191
|
-
for t in &snapshot.todos {
|
|
192
|
-
let icon = match t.status.as_str() {
|
|
193
|
-
"completed" => "✅",
|
|
194
|
-
"in_progress" => "🔄",
|
|
195
|
-
_ => "⏳",
|
|
196
|
-
};
|
|
197
|
-
println!(" {icon} [{}] {}", t.status, t.content);
|
|
198
|
-
}
|
|
199
|
-
println!();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if let Some(ref err) = snapshot.last_error {
|
|
203
|
-
println!("{}", "── Last Error ──".red());
|
|
204
|
-
println!(" {err}");
|
|
205
|
-
println!();
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if !snapshot.decisions.is_empty() {
|
|
209
|
-
println!("{}", "── Decisions ──".cyan());
|
|
210
|
-
for d in &snapshot.decisions {
|
|
211
|
-
println!(" • {d}");
|
|
212
|
-
}
|
|
213
|
-
println!();
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if let Some(ref git) = snapshot.git_state {
|
|
217
|
-
println!("{}", "── Git ──".cyan());
|
|
218
|
-
println!(" Branch: {}", git.branch);
|
|
219
|
-
println!(" {}", git.status_summary);
|
|
220
|
-
if !git.recent_commits.is_empty() {
|
|
221
|
-
println!(" Recent:");
|
|
222
|
-
for c in git.recent_commits.iter().take(3) {
|
|
223
|
-
println!(" {c}");
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
println!();
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if !snapshot.recent_files.is_empty() {
|
|
230
|
-
println!("{}", "── Changed Files ──".cyan());
|
|
231
|
-
for f in snapshot.recent_files.iter().take(10) {
|
|
232
|
-
println!(" {f}");
|
|
233
|
-
}
|
|
234
|
-
println!();
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if !snapshot.conversation.is_empty() {
|
|
238
|
-
println!("{}", format!("── Conversation ({} turns) ──", snapshot.conversation.len()).cyan());
|
|
239
|
-
// Show last 15 turns
|
|
240
|
-
let start = snapshot.conversation.len().saturating_sub(15);
|
|
241
|
-
for turn in &snapshot.conversation[start..] {
|
|
242
|
-
let prefix = match turn.role.as_str() {
|
|
243
|
-
"user" => "👤 USER".to_string(),
|
|
244
|
-
"assistant" => "🤖 CLAUDE".to_string(),
|
|
245
|
-
"assistant_tool" => "🔧 TOOL".to_string(),
|
|
246
|
-
"tool_result" => "📤 RESULT".to_string(),
|
|
247
|
-
_ => turn.role.clone(),
|
|
248
|
-
};
|
|
249
|
-
let content = if turn.content.len() > 120 {
|
|
250
|
-
format!("{}...", &turn.content[..117])
|
|
251
|
-
} else {
|
|
252
|
-
turn.content.clone()
|
|
253
|
-
};
|
|
254
|
-
println!(" {}: {}", prefix, content);
|
|
255
|
-
}
|
|
256
|
-
println!();
|
|
201
|
+
} else {
|
|
202
|
+
tui::print_snapshot(&snapshot);
|
|
257
203
|
}
|
|
258
204
|
}
|
|
259
205
|
|
|
206
|
+
// ═══════════════════════════════════════════════════════════════
|
|
207
|
+
// AGENTS
|
|
208
|
+
// ═══════════════════════════════════════════════════════════════
|
|
260
209
|
Commands::Agents => {
|
|
210
|
+
let sp = if !cli.json { Some(tui::spinner("Checking agents...")) } else { None };
|
|
261
211
|
let statuses = agents::check_all_agents(&config);
|
|
212
|
+
if let Some(sp) = sp { sp.finish_and_clear(); }
|
|
262
213
|
|
|
263
214
|
if cli.json {
|
|
264
215
|
println!("{}", serde_json::to_string_pretty(&statuses)?);
|
|
265
|
-
return Ok(());
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
println!("{}", "═══ Relay Agents ═══".bold());
|
|
269
|
-
println!();
|
|
270
|
-
println!("Priority order: {}", config.general.priority.join(" → "));
|
|
271
|
-
println!();
|
|
272
|
-
|
|
273
|
-
for s in &statuses {
|
|
274
|
-
let icon = if s.available { "✅" } else { "❌" };
|
|
275
|
-
let name = if s.available {
|
|
276
|
-
s.name.green().bold().to_string()
|
|
277
|
-
} else {
|
|
278
|
-
s.name.dimmed().to_string()
|
|
279
|
-
};
|
|
280
|
-
println!(
|
|
281
|
-
" {icon} {:<10} {}",
|
|
282
|
-
name,
|
|
283
|
-
s.reason
|
|
284
|
-
);
|
|
285
|
-
if let Some(ref v) = s.version {
|
|
286
|
-
println!(" Version: {v}");
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
println!();
|
|
290
|
-
|
|
291
|
-
let available = statuses.iter().filter(|s| s.available).count();
|
|
292
|
-
if available == 0 {
|
|
293
|
-
eprintln!("{}", "⚠️ No agents available. Run 'relay init' to configure.".yellow());
|
|
294
216
|
} else {
|
|
295
|
-
|
|
296
|
-
" {} agent{} ready for handoff.",
|
|
297
|
-
available,
|
|
298
|
-
if available == 1 { "" } else { "s" }
|
|
299
|
-
);
|
|
217
|
+
tui::print_agents(&config.general.priority, &statuses);
|
|
300
218
|
}
|
|
301
219
|
}
|
|
302
220
|
|
|
221
|
+
// ═══════════════════════════════════════════════════════════════
|
|
222
|
+
// INIT
|
|
223
|
+
// ═══════════════════════════════════════════════════════════════
|
|
303
224
|
Commands::Init => {
|
|
304
225
|
let path = relay::config_path();
|
|
305
226
|
if path.exists() {
|
|
306
|
-
|
|
307
|
-
|
|
227
|
+
eprintln!(" Config exists: {}", path.display());
|
|
228
|
+
eprintln!(" Edit to add API keys and customize priority.");
|
|
308
229
|
} else {
|
|
309
230
|
Config::save_default(&path)?;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
println!(" api_key = \"your-openai-key\"");
|
|
231
|
+
eprintln!(" ✅ Config created: {}", path.display());
|
|
232
|
+
eprintln!();
|
|
233
|
+
eprintln!(" Add API keys:");
|
|
234
|
+
eprintln!(" [agents.gemini]");
|
|
235
|
+
eprintln!(" api_key = \"your-key\"");
|
|
236
|
+
eprintln!();
|
|
237
|
+
eprintln!(" [agents.openai]");
|
|
238
|
+
eprintln!(" api_key = \"your-key\"");
|
|
319
239
|
}
|
|
320
240
|
}
|
|
321
241
|
|
|
242
|
+
// ═══════════════════════════════════════════════════════════════
|
|
243
|
+
// HOOK
|
|
244
|
+
// ═══════════════════════════════════════════════════════════════
|
|
322
245
|
Commands::Hook { session: _ } => {
|
|
323
246
|
use std::io::Read;
|
|
324
247
|
let mut raw = String::new();
|
|
325
248
|
std::io::stdin().read_to_string(&mut raw)?;
|
|
326
249
|
|
|
327
|
-
// Check for rate limit signals
|
|
328
250
|
if let Some(detection) = relay::detect::check_hook_output(&raw) {
|
|
329
251
|
eprintln!(
|
|
330
|
-
"{}",
|
|
331
|
-
|
|
332
|
-
"🚨 [relay] Rate limit detected in {} output (signal: {})",
|
|
333
|
-
detection.tool_name, detection.signal
|
|
334
|
-
).red().bold()
|
|
252
|
+
" 🚨 Rate limit detected in {} (signal: {})",
|
|
253
|
+
detection.tool_name, detection.signal
|
|
335
254
|
);
|
|
336
|
-
|
|
337
255
|
if config.general.auto_handoff {
|
|
338
|
-
// Auto-handoff
|
|
339
256
|
let snapshot = capture::capture_snapshot(&project_dir, None)?;
|
|
340
|
-
let handoff_text = handoff::build_handoff(
|
|
341
|
-
&snapshot,
|
|
342
|
-
"auto",
|
|
343
|
-
config.general.max_context_tokens,
|
|
344
|
-
)?;
|
|
345
|
-
|
|
257
|
+
let handoff_text = handoff::build_handoff(&snapshot, "auto", config.general.max_context_tokens)?;
|
|
346
258
|
let handoff_path = handoff::save_handoff(&handoff_text, &project_dir)?;
|
|
347
|
-
eprintln!(
|
|
348
|
-
"📄 Handoff saved: {}",
|
|
349
|
-
handoff_path.display()
|
|
350
|
-
);
|
|
351
|
-
|
|
352
259
|
let result = agents::handoff_to_first_available(
|
|
353
|
-
&config,
|
|
354
|
-
&handoff_text,
|
|
355
|
-
&project_dir.to_string_lossy(),
|
|
260
|
+
&config, &handoff_text, &project_dir.to_string_lossy(),
|
|
356
261
|
)?;
|
|
357
|
-
|
|
358
262
|
if result.success {
|
|
359
|
-
eprintln!(
|
|
360
|
-
"{}",
|
|
361
|
-
format!("✅ Auto-handed off to {}", result.agent).green()
|
|
362
|
-
);
|
|
263
|
+
eprintln!(" ✅ Auto-handed off to {}", result.agent);
|
|
363
264
|
} else {
|
|
364
|
-
eprintln!(
|
|
365
|
-
"{}",
|
|
366
|
-
format!("⚠️ No agents available. Handoff saved to: {}",
|
|
367
|
-
handoff_path.display()
|
|
368
|
-
).yellow()
|
|
369
|
-
);
|
|
265
|
+
eprintln!(" 📄 Saved: {}", handoff_path.display());
|
|
370
266
|
}
|
|
371
267
|
}
|
|
372
268
|
}
|
|
373
|
-
|
|
374
|
-
// Always pass through the original output
|
|
375
269
|
print!("{raw}");
|
|
376
270
|
}
|
|
377
271
|
}
|
package/core/src/tui.rs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
//! Beautiful terminal UI for Relay — spinners, boxes, interactive prompts.
|
|
2
|
+
|
|
3
|
+
use colored::Colorize;
|
|
4
|
+
use console::Term;
|
|
5
|
+
use indicatif::{ProgressBar, ProgressStyle};
|
|
6
|
+
use std::time::Duration;
|
|
7
|
+
|
|
8
|
+
// ─── Banner ─────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
pub fn print_banner() {
|
|
11
|
+
let banner = r#"
|
|
12
|
+
╔═══════════════════════════════════════════════╗
|
|
13
|
+
║ ║
|
|
14
|
+
║ ⚡ R E L A Y ║
|
|
15
|
+
║ Cross-agent context handoff ║
|
|
16
|
+
║ ║
|
|
17
|
+
╚═══════════════════════════════════════════════╝
|
|
18
|
+
"#;
|
|
19
|
+
eprintln!("{}", banner.cyan());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Spinners ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
pub fn spinner(msg: &str) -> ProgressBar {
|
|
25
|
+
let pb = ProgressBar::new_spinner();
|
|
26
|
+
pb.set_style(
|
|
27
|
+
ProgressStyle::with_template(" {spinner:.cyan} {msg}")
|
|
28
|
+
.unwrap()
|
|
29
|
+
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✓"]),
|
|
30
|
+
);
|
|
31
|
+
pb.set_message(msg.to_string());
|
|
32
|
+
pb.enable_steady_tick(Duration::from_millis(80));
|
|
33
|
+
pb
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub fn step(num: usize, total: usize, msg: &str) -> ProgressBar {
|
|
37
|
+
let pb = ProgressBar::new_spinner();
|
|
38
|
+
pb.set_style(
|
|
39
|
+
ProgressStyle::with_template(&format!(
|
|
40
|
+
" {{spinner:.cyan}} [{}/{}] {{msg}}",
|
|
41
|
+
num, total
|
|
42
|
+
))
|
|
43
|
+
.unwrap()
|
|
44
|
+
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✓"]),
|
|
45
|
+
);
|
|
46
|
+
pb.set_message(msg.to_string());
|
|
47
|
+
pb.enable_steady_tick(Duration::from_millis(80));
|
|
48
|
+
pb
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Boxes ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
pub fn print_box(title: &str, content: &str) {
|
|
54
|
+
let term_width = Term::stdout().size().1 as usize;
|
|
55
|
+
let width = term_width.min(72).max(40);
|
|
56
|
+
let inner = width - 4;
|
|
57
|
+
|
|
58
|
+
// Top border
|
|
59
|
+
eprintln!(" ╭{}╮", "─".repeat(inner + 2));
|
|
60
|
+
|
|
61
|
+
// Title
|
|
62
|
+
let title_padded = format!(" {} ", title);
|
|
63
|
+
let pad = inner.saturating_sub(title_padded.len()) + 1;
|
|
64
|
+
eprintln!(" │{}{}│", title_padded.bold().cyan(), " ".repeat(pad));
|
|
65
|
+
|
|
66
|
+
// Separator
|
|
67
|
+
eprintln!(" ├{}┤", "─".repeat(inner + 2));
|
|
68
|
+
|
|
69
|
+
// Content lines
|
|
70
|
+
for line in content.lines() {
|
|
71
|
+
let display_line = if line.len() > inner {
|
|
72
|
+
let mut end = inner.saturating_sub(1);
|
|
73
|
+
while end > 0 && !line.is_char_boundary(end) { end -= 1; }
|
|
74
|
+
format!("{}…", &line[..end])
|
|
75
|
+
} else {
|
|
76
|
+
line.to_string()
|
|
77
|
+
};
|
|
78
|
+
let pad = inner.saturating_sub(display_line.len()) + 1;
|
|
79
|
+
eprintln!(" │ {}{} │", display_line, " ".repeat(pad.saturating_sub(1)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Bottom border
|
|
83
|
+
eprintln!(" ╰{}╯", "─".repeat(inner + 2));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pub fn print_section(icon: &str, title: &str) {
|
|
87
|
+
eprintln!();
|
|
88
|
+
eprintln!(" {} {}", icon, title.bold());
|
|
89
|
+
eprintln!(" {}", "─".repeat(50).dimmed());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Agent Select ───────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
pub fn select_agent(agents: &[(String, bool, String)]) -> Option<String> {
|
|
95
|
+
let items: Vec<String> = agents
|
|
96
|
+
.iter()
|
|
97
|
+
.map(|(name, available, reason)| {
|
|
98
|
+
if *available {
|
|
99
|
+
format!("✅ {} — {}", name, reason)
|
|
100
|
+
} else {
|
|
101
|
+
format!("❌ {} — {}", name, reason)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.collect();
|
|
105
|
+
|
|
106
|
+
eprintln!();
|
|
107
|
+
let selection = dialoguer::FuzzySelect::with_theme(
|
|
108
|
+
&dialoguer::theme::ColorfulTheme::default(),
|
|
109
|
+
)
|
|
110
|
+
.with_prompt(" Select target agent")
|
|
111
|
+
.items(&items)
|
|
112
|
+
.default(0)
|
|
113
|
+
.interact_opt()
|
|
114
|
+
.ok()
|
|
115
|
+
.flatten()?;
|
|
116
|
+
|
|
117
|
+
let (name, available, _) = &agents[selection];
|
|
118
|
+
if !*available {
|
|
119
|
+
eprintln!(
|
|
120
|
+
"\n {} {} is not available.",
|
|
121
|
+
"⚠️ ".yellow(),
|
|
122
|
+
name.bold()
|
|
123
|
+
);
|
|
124
|
+
return None;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Some(name.clone())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Status Display ─────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
pub fn print_snapshot(snapshot: &crate::SessionSnapshot) {
|
|
133
|
+
eprintln!();
|
|
134
|
+
let term_width = Term::stdout().size().1 as usize;
|
|
135
|
+
let width = term_width.min(72).max(40);
|
|
136
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
137
|
+
eprintln!(
|
|
138
|
+
" {} {}",
|
|
139
|
+
"📋".to_string(),
|
|
140
|
+
"Session Snapshot".bold().cyan()
|
|
141
|
+
);
|
|
142
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
143
|
+
|
|
144
|
+
// Project + time
|
|
145
|
+
eprintln!();
|
|
146
|
+
eprintln!(" {} {}", "📁", snapshot.project_dir.dimmed());
|
|
147
|
+
eprintln!(" {} {}", "🕐", snapshot.timestamp.dimmed());
|
|
148
|
+
|
|
149
|
+
// Current task
|
|
150
|
+
print_section("🎯", "Current Task");
|
|
151
|
+
eprintln!(" {}", snapshot.current_task);
|
|
152
|
+
|
|
153
|
+
// Todos
|
|
154
|
+
if !snapshot.todos.is_empty() {
|
|
155
|
+
print_section("📝", "Progress");
|
|
156
|
+
for t in &snapshot.todos {
|
|
157
|
+
let (icon, style) = match t.status.as_str() {
|
|
158
|
+
"completed" => ("✅", t.content.dimmed().to_string()),
|
|
159
|
+
"in_progress" => ("🔄", t.content.yellow().bold().to_string()),
|
|
160
|
+
_ => ("⏳", t.content.normal().to_string()),
|
|
161
|
+
};
|
|
162
|
+
eprintln!(" {icon} {style}");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Last error
|
|
167
|
+
if let Some(ref err) = snapshot.last_error {
|
|
168
|
+
print_section("🚨", "Last Error");
|
|
169
|
+
for line in err.lines().take(5) {
|
|
170
|
+
eprintln!(" {}", line.red());
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Decisions
|
|
175
|
+
if !snapshot.decisions.is_empty() {
|
|
176
|
+
print_section("💡", "Key Decisions");
|
|
177
|
+
for d in &snapshot.decisions {
|
|
178
|
+
eprintln!(" • {}", d.dimmed());
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Git
|
|
183
|
+
if let Some(ref git) = snapshot.git_state {
|
|
184
|
+
print_section("🔀", "Git State");
|
|
185
|
+
eprintln!(" Branch: {}", git.branch.green());
|
|
186
|
+
eprintln!(" {}", git.status_summary);
|
|
187
|
+
if !git.recent_commits.is_empty() {
|
|
188
|
+
eprintln!();
|
|
189
|
+
for c in git.recent_commits.iter().take(3) {
|
|
190
|
+
eprintln!(" {}", c.dimmed());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Changed files
|
|
196
|
+
if !snapshot.recent_files.is_empty() {
|
|
197
|
+
print_section("📄", &format!("Changed Files ({})", snapshot.recent_files.len()));
|
|
198
|
+
for f in snapshot.recent_files.iter().take(10) {
|
|
199
|
+
eprintln!(" {f}");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Conversation
|
|
204
|
+
if !snapshot.conversation.is_empty() {
|
|
205
|
+
print_section(
|
|
206
|
+
"💬",
|
|
207
|
+
&format!("Conversation ({} turns)", snapshot.conversation.len()),
|
|
208
|
+
);
|
|
209
|
+
let start = snapshot.conversation.len().saturating_sub(10);
|
|
210
|
+
for turn in &snapshot.conversation[start..] {
|
|
211
|
+
let (prefix, color) = match turn.role.as_str() {
|
|
212
|
+
"user" => ("👤 YOU ", turn.content.normal().to_string()),
|
|
213
|
+
"assistant" => ("🤖 AI ", turn.content.cyan().to_string()),
|
|
214
|
+
"assistant_tool" => ("🔧 TOOL", turn.content.dimmed().to_string()),
|
|
215
|
+
"tool_result" => ("📤 OUT ", turn.content.dimmed().to_string()),
|
|
216
|
+
_ => (" ", turn.content.normal().to_string()),
|
|
217
|
+
};
|
|
218
|
+
let short = if turn.content.len() > 90 {
|
|
219
|
+
let mut end = 85;
|
|
220
|
+
while end > 0 && !turn.content.is_char_boundary(end) { end -= 1; }
|
|
221
|
+
format!("{}…", &turn.content[..end])
|
|
222
|
+
} else {
|
|
223
|
+
turn.content.clone()
|
|
224
|
+
};
|
|
225
|
+
let styled = match turn.role.as_str() {
|
|
226
|
+
"user" => short.normal().to_string(),
|
|
227
|
+
"assistant" => short.cyan().to_string(),
|
|
228
|
+
"assistant_tool" => short.dimmed().to_string(),
|
|
229
|
+
"tool_result" => short.dimmed().to_string(),
|
|
230
|
+
_ => short,
|
|
231
|
+
};
|
|
232
|
+
eprintln!(" {} {}", prefix.dimmed(), styled);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
eprintln!();
|
|
237
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Agents Display ─────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
pub fn print_agents(
|
|
243
|
+
priority: &[String],
|
|
244
|
+
statuses: &[crate::AgentStatus],
|
|
245
|
+
) {
|
|
246
|
+
eprintln!();
|
|
247
|
+
let term_width = Term::stdout().size().1 as usize;
|
|
248
|
+
let width = term_width.min(72).max(40);
|
|
249
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
250
|
+
eprintln!(" {} {}", "🤖", "Available Agents".bold().cyan());
|
|
251
|
+
eprintln!(" {}", "═".repeat(width).cyan());
|
|
252
|
+
eprintln!();
|
|
253
|
+
eprintln!(
|
|
254
|
+
" Priority: {}",
|
|
255
|
+
priority
|
|
256
|
+
.iter()
|
|
257
|
+
.map(|s| s.as_str())
|
|
258
|
+
.collect::<Vec<_>>()
|
|
259
|
+
.join(" → ")
|
|
260
|
+
.dimmed()
|
|
261
|
+
);
|
|
262
|
+
eprintln!();
|
|
263
|
+
|
|
264
|
+
for s in statuses {
|
|
265
|
+
if s.available {
|
|
266
|
+
eprintln!(
|
|
267
|
+
" {} {:<12} {}",
|
|
268
|
+
"✅",
|
|
269
|
+
s.name.green().bold(),
|
|
270
|
+
s.reason.dimmed()
|
|
271
|
+
);
|
|
272
|
+
if let Some(ref v) = s.version {
|
|
273
|
+
eprintln!(" {} {:<12} {}", " ", "", format!("v{v}").dimmed());
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
eprintln!(
|
|
277
|
+
" {} {:<12} {}",
|
|
278
|
+
"❌",
|
|
279
|
+
s.name.dimmed(),
|
|
280
|
+
s.reason.dimmed()
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let available = statuses.iter().filter(|s| s.available).count();
|
|
286
|
+
eprintln!();
|
|
287
|
+
if available == 0 {
|
|
288
|
+
eprintln!(
|
|
289
|
+
" {} {}",
|
|
290
|
+
"⚠️ ",
|
|
291
|
+
"No agents available. Run 'relay init' to configure.".yellow()
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
eprintln!(
|
|
295
|
+
" {} {} agent{} ready for handoff",
|
|
296
|
+
"🚀",
|
|
297
|
+
available.to_string().green().bold(),
|
|
298
|
+
if available == 1 { "" } else { "s" }
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
eprintln!();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Handoff Result ─────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
pub fn print_handoff_success(agent: &str, file: &str) {
|
|
307
|
+
eprintln!();
|
|
308
|
+
eprintln!(
|
|
309
|
+
" {} {}",
|
|
310
|
+
"✅",
|
|
311
|
+
format!("Handed off to {agent}").green().bold()
|
|
312
|
+
);
|
|
313
|
+
eprintln!(" 📄 {}", file.dimmed());
|
|
314
|
+
eprintln!();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
pub fn print_handoff_fail(message: &str, file: &str) {
|
|
318
|
+
eprintln!();
|
|
319
|
+
eprintln!(" {} {}", "❌", message.red());
|
|
320
|
+
eprintln!();
|
|
321
|
+
eprintln!(" 💡 Context saved — copy-paste into any AI:");
|
|
322
|
+
eprintln!(" {}", file.cyan());
|
|
323
|
+
eprintln!();
|
|
324
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@masyv/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|