@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 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
+ }
@@ -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
@@ -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 right now
32
+ /// Hand off current session to a fallback agent
35
33
  Handoff {
36
- /// Force a specific agent (codex, gemini, ollama, openai)
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", "19:00", "30min")
38
+ /// Set deadline urgency (e.g. "7pm", "30min")
41
39
  #[arg(long)]
42
40
  deadline: Option<String>,
43
41
 
44
- /// Don't execute — just print the handoff package
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 (what would be handed off)
55
+ /// Show current session snapshot
58
56
  Status,
59
57
 
60
- /// List configured agents and their availability
58
+ /// List configured agents and availability
61
59
  Agents,
62
60
 
63
- /// Generate default config file at ~/.relay/config.toml
61
+ /// Generate default config at ~/.relay/config.toml
64
62
  Init,
65
63
 
66
- /// PostToolUse hook mode (auto-detect rate limits from stdin)
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
- eprintln!("{}", "⚡ Relay — capturing session state...".yellow().bold());
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
- // Filter sections based on --include flag
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
- snapshot.conversation.clear();
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 target = to.as_deref().unwrap_or("auto");
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 dry_run || cli.json {
133
- if cli.json {
134
- let result = serde_json::json!({
135
- "snapshot": snapshot,
136
- "handoff_text": handoff_text,
137
- "handoff_file": handoff_path.to_string_lossy(),
138
- "target_agent": target,
139
- });
140
- println!("{}", serde_json::to_string_pretty(&result)?);
141
- } else {
142
- println!("{handoff_text}");
143
- eprintln!();
144
- eprintln!("{}", format!("📄 Saved to: {}", handoff_path.display()).dimmed());
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
- eprintln!("{}", format!("📄 Handoff saved: {}", handoff_path.display()).dimmed());
150
- eprintln!();
169
+ // Step 3: Launch agent
170
+ let sp = tui::step(3, 3, &format!("Launching {}...", target_name));
151
171
 
152
- // Execute handoff
153
- let result = if let Some(ref agent_name) = to {
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
- eprintln!("{}", format!("✅ Handed off to {}", result.agent).green().bold());
161
- eprintln!(" {}", result.message);
185
+ tui::print_handoff_success(&result.agent, &handoff_path.to_string_lossy());
162
186
  } else {
163
- eprintln!("{}", format!("❌ Handoff failed: {}", result.message).red());
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
- return Ok(());
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
- println!(
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
- println!("Config already exists at: {}", path.display());
307
- println!("Edit it to add API keys and customize agent priority.");
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
- println!("{}", "✅ Config created at:".green());
311
- println!(" {}", path.display());
312
- println!();
313
- println!("Edit it to add API keys:");
314
- println!(" [agents.gemini]");
315
- println!(" api_key = \"your-gemini-key\"");
316
- println!();
317
- println!(" [agents.openai]");
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
- format!(
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
  }
@@ -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.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",