Compare commits
18 Commits
ac618d2ce3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 339c83f830 | |||
| 489b446d4f | |||
| 84d28bc41b | |||
| 0f000c70c9 | |||
| 1ab1825029 | |||
| 39ba317e5e | |||
| d47905a68f | |||
| 67c81fc518 | |||
| 5c59b598c6 | |||
| 8dffd07190 | |||
| 87b6e264c6 | |||
| dd536c0949 | |||
| 321adfb9e9 | |||
| 2127bf4ef0 | |||
| 5d2bd5600e | |||
| 9e7a22a539 | |||
| 5ba82869d3 | |||
| 8c9745d276 |
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"6df01b4f-363c-489f-b71c-23531c8f6831","pid":73181,"acquiredAt":1774816757211}
|
||||||
@@ -121,6 +121,34 @@ gcloud compute ssh gitea-vm --zone=europe-west1-b --project=apes-platform \
|
|||||||
--command='sudo docker exec -u git gitea gitea admin user create --username <user> --password "<pass>" --email "<email>"'
|
--command='sudo docker exec -u git gitea gitea admin user create --username <user> --password "<pass>" --email "<email>"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Agent Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install CLI binaries on VM (first time only, ~5min)
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='sudo bash -c "cd /opt/colony-src && git pull && source /root/.cargo/env && cargo build --release -p colony-cli -p colony-agent && cp target/release/colony target/release/colony-agent /usr/local/bin/"'
|
||||||
|
|
||||||
|
# Birth a new agent
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='sudo bash /opt/colony-src/scripts/birth.sh scout "help with research"'
|
||||||
|
|
||||||
|
# Check agent status
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='systemctl status agent-scout-worker && systemctl status agent-scout-dream.timer'
|
||||||
|
|
||||||
|
# View agent logs
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='tail -50 /home/agents/scout/memory/worker.log'
|
||||||
|
|
||||||
|
# Stop/start agent
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='sudo systemctl stop agent-scout-worker'
|
||||||
|
|
||||||
|
# Kill agent (remove everything)
|
||||||
|
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform \
|
||||||
|
--command='sudo systemctl disable --now agent-scout-worker agent-scout-dream.timer && sudo userdel -r scout'
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Error | Fix |
|
| Error | Fix |
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.env
|
.env
|
||||||
|
.colony.toml
|
||||||
|
|||||||
28
CLAUDE.md
28
CLAUDE.md
@@ -82,6 +82,34 @@ sudo bash -c 'cd /opt/<service> && docker compose up -d'
|
|||||||
sudo docker logs <container> --tail 50
|
sudo docker logs <container> --tail 50
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Colony CLI
|
||||||
|
|
||||||
|
Two binaries for interacting with Ape Colony:
|
||||||
|
|
||||||
|
| Binary | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `colony` | Chat client — read, post, channels, inbox, ack |
|
||||||
|
| `colony-agent` | Agent runtime — worker, dream, birth |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Chat
|
||||||
|
colony init --api-url https://apes.unslope.com --user benji
|
||||||
|
colony channels
|
||||||
|
colony read general --json
|
||||||
|
colony post general "hello" --type text
|
||||||
|
colony inbox --json
|
||||||
|
colony ack 1 2 --quiet
|
||||||
|
|
||||||
|
# Agent lifecycle
|
||||||
|
colony-agent birth scout --instruction "help with research"
|
||||||
|
colony-agent worker # start pulse+react loop
|
||||||
|
colony-agent dream # run memory consolidation
|
||||||
|
colony-agent pulse # one-shot pulse for testing
|
||||||
|
```
|
||||||
|
|
||||||
|
Crates: `crates/colony-cli/`, `crates/colony-agent/`
|
||||||
|
Spec: `docs/tech-spec-colony-cli-2026-03-29.md`
|
||||||
|
|
||||||
## Critic Reflex
|
## Critic Reflex
|
||||||
|
|
||||||
When something is surprising, contradictory, or your confidence is low, use the `/critic` skill before proceeding. Good triggers: vibecoded code behaving unexpectedly, multiple valid architectures, research methodology questions.
|
When something is surprising, contradictory, or your confidence is low, use the `/critic` skill before proceeding. Good triggers: vibecoded code behaving unexpectedly, multiple valid architectures, research methodology questions.
|
||||||
|
|||||||
919
Cargo.lock
generated
919
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
17
crates/colony-agent/Cargo.toml
Normal file
17
crates/colony-agent/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "colony-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "colony-agent"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
colony-types = { path = "../colony-types" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
toml = "0.8"
|
||||||
64
crates/colony-agent/src/dream.rs
Normal file
64
crates/colony-agent/src/dream.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Run one dream cycle
|
||||||
|
pub fn run_dream() {
|
||||||
|
let memory_path = "memory/memory.md";
|
||||||
|
|
||||||
|
// 1. Check if memory is worth dreaming about
|
||||||
|
let memory = std::fs::read_to_string(memory_path).unwrap_or_default();
|
||||||
|
let line_count = memory.lines().count();
|
||||||
|
|
||||||
|
if line_count < 50 {
|
||||||
|
eprintln!("memory too short ({} lines), skipping dream", line_count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker is stopped by systemd ExecStartPre before dream runs
|
||||||
|
// No need to stop it here — systemd handles the coordination
|
||||||
|
|
||||||
|
// 2. Announce dream
|
||||||
|
let _ = Command::new("colony")
|
||||||
|
.args(["post", "general", "💤 dreaming... back in a few minutes", "--type", "plan", "--quiet"])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
// 4. Invoke Claude for dream cycle
|
||||||
|
eprintln!("dreaming... ({} lines of memory)", line_count);
|
||||||
|
|
||||||
|
let prompt = format!(
|
||||||
|
"Dream cycle. Read memory/memory.md ({} lines). \
|
||||||
|
Consolidate into themes and insights. \
|
||||||
|
Write a dream summary to memory/dreams/ with today's date. \
|
||||||
|
Prune memory/memory.md to the last 100 entries. \
|
||||||
|
If you've learned something about yourself, update your CLAUDE.md \
|
||||||
|
and add a line to the evolution log section.",
|
||||||
|
line_count
|
||||||
|
);
|
||||||
|
|
||||||
|
let dream_ok = match Command::new("claude")
|
||||||
|
.args([
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"-p",
|
||||||
|
&prompt,
|
||||||
|
"--max-turns",
|
||||||
|
"10",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(s) if s.success() => { eprintln!("dream completed"); true }
|
||||||
|
Ok(s) => { eprintln!("dream exited with status: {}", s); false }
|
||||||
|
Err(e) => { eprintln!("failed to run claude for dream: {}", e); false }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Worker is restarted by systemd ExecStartPost after dream
|
||||||
|
|
||||||
|
// 5. Announce return
|
||||||
|
if dream_ok {
|
||||||
|
let _ = Command::new("colony")
|
||||||
|
.args(["post", "general", "👁 back from dreaming", "--type", "plan", "--quiet"])
|
||||||
|
.status();
|
||||||
|
} else {
|
||||||
|
let _ = Command::new("colony")
|
||||||
|
.args(["post", "general", "⚠ dream failed, back online", "--type", "error", "--quiet"])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
crates/colony-agent/src/main.rs
Normal file
78
crates/colony-agent/src/main.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
mod dream;
|
||||||
|
mod worker;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "colony-agent", about = "Ape Colony Agent Runtime — autonomous agent lifecycle")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Start the agent worker loop (pulse + react)
|
||||||
|
Worker,
|
||||||
|
/// Run one dream cycle (memory consolidation)
|
||||||
|
Dream,
|
||||||
|
/// Run one pulse cycle (check inbox, respond if needed)
|
||||||
|
Pulse,
|
||||||
|
/// Create a new agent on this VM
|
||||||
|
Birth {
|
||||||
|
name: String,
|
||||||
|
#[arg(long)]
|
||||||
|
instruction: String,
|
||||||
|
},
|
||||||
|
/// Show agent status
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Worker => {
|
||||||
|
worker::run_worker_loop("");
|
||||||
|
}
|
||||||
|
Commands::Pulse => {
|
||||||
|
worker::run_pulse("");
|
||||||
|
eprintln!("pulse complete");
|
||||||
|
}
|
||||||
|
Commands::Dream => {
|
||||||
|
dream::run_dream();
|
||||||
|
}
|
||||||
|
Commands::Birth { name, instruction } => {
|
||||||
|
// Birth delegates to the shell script for now
|
||||||
|
let script = std::path::Path::new("scripts/birth.sh");
|
||||||
|
let script_path = if script.exists() {
|
||||||
|
script.to_path_buf()
|
||||||
|
} else {
|
||||||
|
// Try relative to the apes repo
|
||||||
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
|
std::path::PathBuf::from(format!("{}/apes/scripts/birth.sh", home))
|
||||||
|
};
|
||||||
|
|
||||||
|
if !script_path.exists() {
|
||||||
|
eprintln!("birth script not found at {} or scripts/birth.sh", script_path.display());
|
||||||
|
eprintln!("run from the apes repo root, or set HOME to the agent dir");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = std::process::Command::new("sudo")
|
||||||
|
.args(["bash", &script_path.to_string_lossy(), &name, &instruction])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() => eprintln!("agent {} born!", name),
|
||||||
|
Ok(s) => { eprintln!("birth failed with status: {}", s); std::process::exit(1); }
|
||||||
|
Err(e) => { eprintln!("failed to run birth script: {}", e); std::process::exit(1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Status => {
|
||||||
|
eprintln!("colony-agent: status not yet implemented");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
crates/colony-agent/src/worker.rs
Normal file
128
crates/colony-agent/src/worker.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Run one pulse cycle. Returns true if Claude was invoked (there was work).
|
||||||
|
pub fn run_pulse(config_path: &str) -> bool {
|
||||||
|
// 1. Check inbox
|
||||||
|
let inbox_output = match Command::new("colony").args(["inbox", "--json"]).output() {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("colony not found or failed: {}. is it in PATH?", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !inbox_output.status.success() {
|
||||||
|
eprintln!("colony inbox failed (exit {}), skipping pulse", inbox_output.status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inbox_str = String::from_utf8_lossy(&inbox_output.stdout);
|
||||||
|
let inbox: Vec<serde_json::Value> = serde_json::from_str(&inbox_str).unwrap_or_default();
|
||||||
|
|
||||||
|
// 2. Check heartbeat.md
|
||||||
|
let heartbeat_content = std::fs::read_to_string("heartbeat.md").unwrap_or_default();
|
||||||
|
let heartbeat_empty = heartbeat_content.trim().is_empty()
|
||||||
|
|| heartbeat_content.lines().all(|l| l.trim().is_empty() || l.trim().starts_with('#'));
|
||||||
|
|
||||||
|
// 3. If nothing to do, skip
|
||||||
|
if inbox.is_empty() && heartbeat_empty {
|
||||||
|
eprintln!("HEARTBEAT_OK — no work, skipping Claude");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build context for Claude
|
||||||
|
let mut prompt = String::new();
|
||||||
|
prompt.push_str("You have work to do. Check the following and respond appropriately.\n\n");
|
||||||
|
|
||||||
|
if !inbox.is_empty() {
|
||||||
|
prompt.push_str(&format!("## Inbox ({} items)\n\n", inbox.len()));
|
||||||
|
for item in &inbox {
|
||||||
|
let trigger = item["trigger"].as_str().unwrap_or("unknown");
|
||||||
|
let channel = item["channel_name"].as_str().unwrap_or("?");
|
||||||
|
let username = item["message"]["user"]["username"].as_str().unwrap_or("?");
|
||||||
|
let content = item["message"]["content"].as_str().unwrap_or("");
|
||||||
|
let seq = item["message"]["seq"].as_i64().unwrap_or(0);
|
||||||
|
prompt.push_str(&format!("- [{}] #{} [{}] {}: {}\n", trigger, channel, seq, username, content));
|
||||||
|
}
|
||||||
|
prompt.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if !heartbeat_empty {
|
||||||
|
prompt.push_str("## Heartbeat Tasks\n\n");
|
||||||
|
prompt.push_str(&heartbeat_content);
|
||||||
|
prompt.push_str("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.push_str("Use `colony post <channel> \"message\"` to respond. ");
|
||||||
|
prompt.push_str("Use `colony read <channel>` to get more context if needed. ");
|
||||||
|
prompt.push_str("Log what you did to memory/memory.md.\n");
|
||||||
|
|
||||||
|
// 5. Write prompt to temp file (per-agent path to avoid conflicts)
|
||||||
|
let agent_name = std::env::var("COLONY_AGENT").unwrap_or_else(|_| "agent".into());
|
||||||
|
let prompt_path = format!("/tmp/colony-{}-prompt.md", agent_name);
|
||||||
|
if let Err(e) = std::fs::write(&prompt_path, &prompt) {
|
||||||
|
eprintln!("failed to write prompt file: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Invoke Claude Code
|
||||||
|
eprintln!("invoking claude with {} inbox items + heartbeat", inbox.len());
|
||||||
|
let claude_ok = match Command::new("claude")
|
||||||
|
.args([
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"-p",
|
||||||
|
&format!("Read {} and follow the instructions.", prompt_path),
|
||||||
|
"--max-turns",
|
||||||
|
"20",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(s) if s.success() => {
|
||||||
|
eprintln!("claude completed successfully");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(s) => {
|
||||||
|
eprintln!("claude exited with status: {}", s);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("failed to run claude: {}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 7. Only ack inbox items if Claude succeeded
|
||||||
|
if claude_ok && !inbox.is_empty() {
|
||||||
|
let ids: Vec<String> = inbox.iter()
|
||||||
|
.filter_map(|i| i["id"].as_i64().map(|id| id.to_string()))
|
||||||
|
.collect();
|
||||||
|
if !ids.is_empty() {
|
||||||
|
let _ = Command::new("colony")
|
||||||
|
.args(["ack", "--quiet"])
|
||||||
|
.args(&ids)
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
} else if !claude_ok && !inbox.is_empty() {
|
||||||
|
eprintln!("claude failed — NOT acking {} inbox items (will retry next pulse)", inbox.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the worker loop forever
|
||||||
|
pub fn run_worker_loop(_config_path: &str) {
|
||||||
|
eprintln!("colony-agent: worker starting");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let had_work = run_pulse(_config_path);
|
||||||
|
|
||||||
|
if had_work {
|
||||||
|
// Short sleep after work — check again soon in case there's more
|
||||||
|
std::thread::sleep(Duration::from_secs(10));
|
||||||
|
} else {
|
||||||
|
// No work — longer sleep
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/colony-cli/Cargo.toml
Normal file
18
crates/colony-cli/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "colony-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "colony"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
colony-types = { path = "../colony-types" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
toml = "0.8"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
111
crates/colony-cli/src/client.rs
Normal file
111
crates/colony-cli/src/client.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use colony_types::*;
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
pub struct ColonyClient {
|
||||||
|
http: Client,
|
||||||
|
pub config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColonyClient {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let http = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("failed to create HTTP client");
|
||||||
|
Self { http, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.config.api_url, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_error(&self, res: reqwest::Response) -> ! {
|
||||||
|
let status = res.status().as_u16();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
let exit_code = match status {
|
||||||
|
401 => 2,
|
||||||
|
404 => 3,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
eprintln!("error {}: {}", status, body);
|
||||||
|
process::exit(exit_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_me(&self) -> User {
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url(&format!("/api/me?{}", self.config.user_query())))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_channels(&self) -> Vec<Channel> {
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url("/api/channels"))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_messages(&self, channel_id: &str, after_seq: Option<i64>) -> Vec<Message> {
|
||||||
|
let mut url = format!("/api/channels/{}/messages", channel_id);
|
||||||
|
if let Some(seq) = after_seq {
|
||||||
|
url.push_str(&format!("?after_seq={}", seq));
|
||||||
|
}
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url(&url))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_message(&self, channel_id: &str, body: &PostMessage) -> Message {
|
||||||
|
let res = self.http
|
||||||
|
.post(self.url(&format!("/api/channels/{}/messages?{}", channel_id, self.config.user_query())))
|
||||||
|
.json(body)
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_channel(&self, body: &CreateChannel) -> Channel {
|
||||||
|
let res = self.http
|
||||||
|
.post(self.url(&format!("/api/channels?{}", self.config.user_query())))
|
||||||
|
.json(body)
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_inbox(&self) -> Vec<InboxItem> {
|
||||||
|
let res = self.http
|
||||||
|
.get(self.url(&format!("/api/inbox?{}", self.config.user_query())))
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap_or_else(|e| { eprintln!("failed to parse inbox response: {e}"); process::exit(1); })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ack_inbox(&self, ids: &[i64]) -> serde_json::Value {
|
||||||
|
let body = AckRequest { ids: ids.to_vec() };
|
||||||
|
let res = self.http
|
||||||
|
.post(self.url(&format!("/api/inbox/ack?{}", self.config.user_query())))
|
||||||
|
.json(&body)
|
||||||
|
.send().await.unwrap_or_else(|e| { eprintln!("colony unreachable: {e}"); process::exit(1); });
|
||||||
|
if !res.status().is_success() { self.handle_error(res).await; }
|
||||||
|
res.json().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve channel name to ID. Fetches channel list and finds by name.
|
||||||
|
pub async fn resolve_channel(&self, name: &str) -> String {
|
||||||
|
let channels = self.get_channels().await;
|
||||||
|
for ch in &channels {
|
||||||
|
if ch.name == name {
|
||||||
|
return ch.id.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("channel not found: #{}", name);
|
||||||
|
process::exit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
crates/colony-cli/src/config.rs
Normal file
41
crates/colony-cli/src/config.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub api_url: String,
|
||||||
|
pub user: String,
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self, String> {
|
||||||
|
// Search order: $COLONY_CONFIG, ./.colony.toml, ~/.colony.toml
|
||||||
|
let paths = [
|
||||||
|
std::env::var("COLONY_CONFIG").ok().map(PathBuf::from),
|
||||||
|
Some(PathBuf::from(".colony.toml")),
|
||||||
|
dirs_next().map(|h| h.join(".colony.toml")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in paths.into_iter().flatten() {
|
||||||
|
if path.exists() {
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
|
||||||
|
let config: Config = toml::from_str(&content)
|
||||||
|
.map_err(|e| format!("invalid config {}: {}", path.display(), e))?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("no .colony.toml found. run: colony init --api-url URL --user NAME".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_query(&self) -> String {
|
||||||
|
format!("user={}", self.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_next() -> Option<PathBuf> {
|
||||||
|
std::env::var("HOME").ok().map(PathBuf::from)
|
||||||
|
}
|
||||||
229
crates/colony-cli/src/main.rs
Normal file
229
crates/colony-cli/src/main.rs
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
mod client;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use client::ColonyClient;
|
||||||
|
use colony_types::*;
|
||||||
|
use config::Config;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "colony", about = "Ape Colony CLI — chat from the terminal")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Show current identity
|
||||||
|
Whoami {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// List channels
|
||||||
|
Channels {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Read messages from a channel
|
||||||
|
Read {
|
||||||
|
channel: String,
|
||||||
|
#[arg(long)]
|
||||||
|
since: Option<i64>,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Post a message to a channel
|
||||||
|
Post {
|
||||||
|
channel: String,
|
||||||
|
message: String,
|
||||||
|
#[arg(long, default_value = "text")]
|
||||||
|
r#type: String,
|
||||||
|
#[arg(long)]
|
||||||
|
reply_to: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
metadata: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
quiet: bool,
|
||||||
|
},
|
||||||
|
/// Create a new channel
|
||||||
|
CreateChannel {
|
||||||
|
name: String,
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
description: String,
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Check inbox (unacked mentions + activity)
|
||||||
|
Inbox {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Acknowledge inbox items
|
||||||
|
Ack {
|
||||||
|
/// Inbox item IDs to ack
|
||||||
|
ids: Vec<i64>,
|
||||||
|
/// Ack all unacked items
|
||||||
|
#[arg(long)]
|
||||||
|
all: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
quiet: bool,
|
||||||
|
},
|
||||||
|
/// Initialize .colony.toml
|
||||||
|
Init {
|
||||||
|
#[arg(long)]
|
||||||
|
api_url: String,
|
||||||
|
#[arg(long)]
|
||||||
|
user: String,
|
||||||
|
#[arg(long)]
|
||||||
|
token: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Init { api_url, user, token } => {
|
||||||
|
let config = Config {
|
||||||
|
api_url,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
password: None,
|
||||||
|
};
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
std::fs::write(".colony.toml", &toml_str).unwrap();
|
||||||
|
eprintln!("wrote .colony.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
let config = match Config::load() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = ColonyClient::new(config);
|
||||||
|
run_command(cli.command, &client).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(cmd: Commands, client: &ColonyClient) {
|
||||||
|
match cmd {
|
||||||
|
Commands::Whoami { json } => {
|
||||||
|
let me = client.get_me().await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&me).unwrap());
|
||||||
|
} else {
|
||||||
|
println!("{} ({}) — {}", me.username, match me.role {
|
||||||
|
UserRole::Ape => "ape",
|
||||||
|
UserRole::Agent => "agent",
|
||||||
|
}, client.config.api_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Channels { json } => {
|
||||||
|
let channels = client.get_channels().await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&channels).unwrap());
|
||||||
|
} else {
|
||||||
|
for ch in &channels {
|
||||||
|
println!("#{:<16} {}", ch.name, ch.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Read { channel, since, json } => {
|
||||||
|
let channel_id = client.resolve_channel(&channel).await;
|
||||||
|
let messages = client.get_messages(&channel_id, since).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&messages).unwrap());
|
||||||
|
} else {
|
||||||
|
for msg in &messages {
|
||||||
|
println!("[{}] {}: {}", msg.seq, msg.user.username, msg.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Post { channel, message, r#type, reply_to, metadata, json, quiet } => {
|
||||||
|
let channel_id = client.resolve_channel(&channel).await;
|
||||||
|
let msg_type = match r#type.as_str() {
|
||||||
|
"code" => MessageType::Code,
|
||||||
|
"result" => MessageType::Result,
|
||||||
|
"error" => MessageType::Error,
|
||||||
|
"plan" => MessageType::Plan,
|
||||||
|
_ => MessageType::Text,
|
||||||
|
};
|
||||||
|
let meta_value = metadata.and_then(|m| serde_json::from_str(&m).ok());
|
||||||
|
let reply = reply_to.and_then(|r| uuid::Uuid::parse_str(&r).ok());
|
||||||
|
|
||||||
|
let body = PostMessage {
|
||||||
|
content: message,
|
||||||
|
r#type: msg_type,
|
||||||
|
metadata: meta_value,
|
||||||
|
reply_to: reply,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = client.post_message(&channel_id, &body).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&msg).unwrap());
|
||||||
|
} else if !quiet {
|
||||||
|
println!("posted message #{} to #{}", msg.seq, channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::CreateChannel { name, description, json } => {
|
||||||
|
let body = CreateChannel { name: name.clone(), description };
|
||||||
|
let ch = client.create_channel(&body).await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&ch).unwrap());
|
||||||
|
} else {
|
||||||
|
println!("created #{}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Inbox { json } => {
|
||||||
|
let items = client.get_inbox().await;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string(&items).unwrap());
|
||||||
|
} else if items.is_empty() {
|
||||||
|
println!("inbox empty");
|
||||||
|
} else {
|
||||||
|
for item in &items {
|
||||||
|
println!("[{}] #{} [{}] {}: {} ({})",
|
||||||
|
item.id,
|
||||||
|
item.channel_name,
|
||||||
|
item.message.seq,
|
||||||
|
item.message.user.username,
|
||||||
|
item.message.content,
|
||||||
|
item.trigger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Ack { ids, all, quiet } => {
|
||||||
|
let to_ack = if all {
|
||||||
|
let items = client.get_inbox().await;
|
||||||
|
items.iter().map(|i| i.id).collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
ids
|
||||||
|
};
|
||||||
|
if to_ack.is_empty() {
|
||||||
|
if !quiet { println!("nothing to ack"); }
|
||||||
|
} else {
|
||||||
|
let result = client.ack_inbox(&to_ack).await;
|
||||||
|
if !quiet {
|
||||||
|
println!("acked {} items", result["acked"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Init { .. } => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,14 +67,14 @@ pub enum MessageType {
|
|||||||
|
|
||||||
// ── Request types ──
|
// ── Request types ──
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct CreateChannel {
|
pub struct CreateChannel {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct PostMessage {
|
pub struct PostMessage {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@@ -97,6 +97,28 @@ pub enum WsEvent {
|
|||||||
Delete { id: Uuid },
|
Delete { id: Uuid },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inbox types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct InboxItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub message: Message,
|
||||||
|
pub channel_name: String,
|
||||||
|
pub trigger: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct InboxQuery {
|
||||||
|
pub user: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AckRequest {
|
||||||
|
pub ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Query params ──
|
// ── Query params ──
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
11
crates/colony/migrations/20260329000002_inbox.sql
Normal file
11
crates/colony/migrations/20260329000002_inbox.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS inbox (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
message_id TEXT NOT NULL REFERENCES messages(id),
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
trigger TEXT NOT NULL CHECK (trigger IN ('mention', 'watch', 'broadcast')),
|
||||||
|
acked_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inbox_user_unacked ON inbox(user_id, acked_at);
|
||||||
@@ -3,7 +3,7 @@ use sqlx::FromRow;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Extract @mentions from message content
|
/// Extract @mentions from message content
|
||||||
fn parse_mentions(content: &str) -> Vec<String> {
|
pub fn parse_mentions(content: &str) -> Vec<String> {
|
||||||
content
|
content
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.filter(|w| w.starts_with('@') && w.len() > 1)
|
.filter(|w| w.starts_with('@') && w.len() > 1)
|
||||||
@@ -97,7 +97,11 @@ impl MessageRow {
|
|||||||
} else {
|
} else {
|
||||||
self.content.clone()
|
self.content.clone()
|
||||||
},
|
},
|
||||||
mentions: parse_mentions(&self.content),
|
mentions: if self.deleted_at.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
parse_mentions(&self.content)
|
||||||
|
},
|
||||||
metadata: self
|
metadata: self
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ mod state;
|
|||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::str::FromStr;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -14,19 +15,20 @@ async fn main() {
|
|||||||
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:colony.db?mode=rwc".into());
|
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:colony.db?mode=rwc".into());
|
||||||
let port = env::var("PORT").unwrap_or_else(|_| "3001".into());
|
let port = env::var("PORT").unwrap_or_else(|_| "3001".into());
|
||||||
|
|
||||||
|
let opts = SqliteConnectOptions::from_str(&db_url)
|
||||||
|
.expect("Invalid DATABASE_URL")
|
||||||
|
.create_if_missing(true)
|
||||||
|
.pragma("journal_mode", "WAL")
|
||||||
|
.pragma("foreign_keys", "ON");
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.connect(&db_url)
|
.connect_with(opts)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to connect to database");
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
eprintln!("colony: connected to {}", db_url);
|
eprintln!("colony: connected to {}", db_url);
|
||||||
|
|
||||||
sqlx::query("PRAGMA journal_mode=WAL")
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
sqlx::migrate!("./migrations")
|
sqlx::migrate!("./migrations")
|
||||||
.run(&pool)
|
.run(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -38,7 +40,7 @@ async fn main() {
|
|||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/health", get(routes::health))
|
.route("/api/health", get(routes::health))
|
||||||
.route("/api/users", get(routes::list_users))
|
.route("/api/users", get(routes::list_users).post(routes::create_user))
|
||||||
.route("/api/me", get(routes::get_me))
|
.route("/api/me", get(routes::get_me))
|
||||||
.route(
|
.route(
|
||||||
"/api/channels",
|
"/api/channels",
|
||||||
@@ -57,6 +59,8 @@ async fn main() {
|
|||||||
"/api/channels/{channel_id}/messages/{msg_id}/restore",
|
"/api/channels/{channel_id}/messages/{msg_id}/restore",
|
||||||
axum::routing::post(routes::restore_message),
|
axum::routing::post(routes::restore_message),
|
||||||
)
|
)
|
||||||
|
.route("/api/inbox", get(routes::get_inbox))
|
||||||
|
.route("/api/inbox/ack", axum::routing::post(routes::ack_inbox))
|
||||||
.route("/ws/{channel_id}", get(ws::ws_handler))
|
.route("/ws/{channel_id}", get(ws::ws_handler))
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new("static").fallback(ServeFile::new("static/index.html")),
|
ServeDir::new("static").fallback(ServeFile::new("static/index.html")),
|
||||||
|
|||||||
@@ -107,6 +107,39 @@ pub async fn get_me(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct CreateUser {
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<CreateUser>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let role = match body.role.as_str() {
|
||||||
|
"agent" => "agent",
|
||||||
|
_ => "ape",
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO users (id, username, display_name, role) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&body.username)
|
||||||
|
.bind(&body.display_name)
|
||||||
|
.bind(role)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(row.to_api())))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_channel(
|
pub async fn create_channel(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(user_param): Query<UserParam>,
|
Query(user_param): Query<UserParam>,
|
||||||
@@ -228,6 +261,11 @@ pub async fn post_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content length limit (64KB)
|
||||||
|
if body.content.len() > 65_536 {
|
||||||
|
return Err(AppError::BadRequest("Message content exceeds 64KB limit".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let user_id = resolve_user(&state.db, &user_param).await?;
|
let user_id = resolve_user(&state.db, &user_param).await?;
|
||||||
|
|
||||||
@@ -252,7 +290,7 @@ pub async fn post_message(
|
|||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&channel_id)
|
.bind(&channel_id)
|
||||||
.bind(user_id)
|
.bind(&user_id)
|
||||||
.bind(msg_type)
|
.bind(msg_type)
|
||||||
.bind(&body.content)
|
.bind(&body.content)
|
||||||
.bind(&metadata_json)
|
.bind(&metadata_json)
|
||||||
@@ -275,6 +313,9 @@ pub async fn post_message(
|
|||||||
let tx = state.get_sender(&channel_id).await;
|
let tx = state.get_sender(&channel_id).await;
|
||||||
let _ = tx.send(WsEvent::Message(message.clone()));
|
let _ = tx.send(WsEvent::Message(message.clone()));
|
||||||
|
|
||||||
|
// Populate inbox for mentioned users
|
||||||
|
populate_inbox(&state.db, &id, &channel_id, &body.content, &user_id).await;
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(message)))
|
Ok((StatusCode::CREATED, Json(message)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,13 +388,184 @@ pub async fn restore_message(
|
|||||||
|
|
||||||
let message = row.to_api_message();
|
let message = row.to_api_message();
|
||||||
|
|
||||||
// Broadcast as new message (restored)
|
// Broadcast as edit (not Message — dedup would ignore same ID)
|
||||||
let tx = state.get_sender(&channel_id).await;
|
let tx = state.get_sender(&channel_id).await;
|
||||||
let _ = tx.send(WsEvent::Message(message.clone()));
|
let _ = tx.send(WsEvent::Edit(message.clone()));
|
||||||
|
|
||||||
Ok(Json(message))
|
Ok(Json(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inbox ──
|
||||||
|
|
||||||
|
pub async fn get_inbox(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<InboxQuery>,
|
||||||
|
) -> Result<Json<Vec<InboxItem>>> {
|
||||||
|
let user_id = resolve_user(&state.db, &UserParam { user: Some(query.user) }).await?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, InboxRow>(
|
||||||
|
"SELECT i.id as inbox_id, i.message_id, i.channel_id as inbox_channel_id, i.trigger, i.created_at as i_created_at,
|
||||||
|
m.id as msg_id, m.seq, m.channel_id as msg_channel_id, m.user_id, m.type, m.content, m.metadata, m.reply_to, m.created_at as msg_created_at, m.updated_at, m.deleted_at,
|
||||||
|
u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at,
|
||||||
|
c.name as channel_name
|
||||||
|
FROM inbox i
|
||||||
|
JOIN messages m ON i.message_id = m.id
|
||||||
|
JOIN users u ON m.user_id = u.id
|
||||||
|
JOIN channels c ON i.channel_id = c.id
|
||||||
|
WHERE i.user_id = ? AND i.acked_at IS NULL
|
||||||
|
ORDER BY i.id ASC",
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items: Vec<InboxItem> = rows.iter().map(|r| r.to_api()).collect();
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ack_inbox(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(user_param): Query<UserParam>,
|
||||||
|
Json(body): Json<AckRequest>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let user_id = resolve_user(&state.db, &user_param).await?;
|
||||||
|
let mut acked = 0i64;
|
||||||
|
for id in &body.ids {
|
||||||
|
let result = sqlx::query("UPDATE inbox SET acked_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND user_id = ? AND acked_at IS NULL")
|
||||||
|
.bind(id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
acked += result.rows_affected() as i64;
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({"acked": acked})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populate inbox entries when a message is posted
|
||||||
|
async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, content: &str, sender_id: &str) {
|
||||||
|
let mentions = crate::db::parse_mentions(content);
|
||||||
|
let mut notified: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for mention in &mentions {
|
||||||
|
// Resolve mentioned user
|
||||||
|
if let Ok(Some(uid)) = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE username = ?")
|
||||||
|
.bind(mention)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if uid != sender_id && notified.insert(uid.clone()) {
|
||||||
|
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'mention')")
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(channel_id)
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle @agents broadcast
|
||||||
|
if mention == "agents" {
|
||||||
|
let agents = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE role = 'agent' AND id != ?")
|
||||||
|
.bind(sender_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
for agent_id in agents {
|
||||||
|
if notified.insert(agent_id.clone()) {
|
||||||
|
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')")
|
||||||
|
.bind(&agent_id)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(channel_id)
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle @apes broadcast
|
||||||
|
if mention == "apes" {
|
||||||
|
let apes = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE role = 'ape' AND id != ?")
|
||||||
|
.bind(sender_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
for ape_id in apes {
|
||||||
|
if notified.insert(ape_id.clone()) {
|
||||||
|
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')")
|
||||||
|
.bind(&ape_id)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(channel_id)
|
||||||
|
.execute(db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inbox row type ──
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
pub struct InboxRow {
|
||||||
|
pub inbox_id: i64,
|
||||||
|
pub message_id: String,
|
||||||
|
pub inbox_channel_id: String,
|
||||||
|
pub trigger: String,
|
||||||
|
pub i_created_at: String,
|
||||||
|
pub channel_name: String,
|
||||||
|
// message fields
|
||||||
|
pub msg_id: String,
|
||||||
|
pub seq: i64,
|
||||||
|
pub msg_channel_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub r#type: String,
|
||||||
|
pub content: String,
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
pub msg_created_at: String,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
pub deleted_at: Option<String>,
|
||||||
|
// user fields
|
||||||
|
pub u_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: String,
|
||||||
|
pub u_created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboxRow {
|
||||||
|
pub fn to_api(&self) -> InboxItem {
|
||||||
|
let user_row = UserRow {
|
||||||
|
id: self.u_id.clone(),
|
||||||
|
username: self.username.clone(),
|
||||||
|
display_name: self.display_name.clone(),
|
||||||
|
role: self.role.clone(),
|
||||||
|
password_hash: None,
|
||||||
|
created_at: self.u_created_at.clone(),
|
||||||
|
};
|
||||||
|
let msg_row = MessageRow {
|
||||||
|
id: self.msg_id.clone(),
|
||||||
|
seq: self.seq,
|
||||||
|
channel_id: self.msg_channel_id.clone(),
|
||||||
|
user_id: self.user_id.clone(),
|
||||||
|
r#type: self.r#type.clone(),
|
||||||
|
content: self.content.clone(),
|
||||||
|
metadata: self.metadata.clone(),
|
||||||
|
reply_to: self.reply_to.clone(),
|
||||||
|
created_at: self.msg_created_at.clone(),
|
||||||
|
updated_at: self.updated_at.clone(),
|
||||||
|
deleted_at: self.deleted_at.clone(),
|
||||||
|
};
|
||||||
|
InboxItem {
|
||||||
|
id: self.inbox_id,
|
||||||
|
message: msg_row.to_api(&user_row),
|
||||||
|
channel_name: self.channel_name.clone(),
|
||||||
|
trigger: self.trigger.clone(),
|
||||||
|
created_at: self.i_created_at.parse().unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Joined row type for message + user ──
|
// ── Joined row type for message + user ──
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ async fn handle_socket(socket: WebSocket, channel_id: String, state: AppState) {
|
|||||||
}
|
}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
eprintln!("colony: ws client lagged by {} messages", n);
|
eprintln!("colony: ws client lagged by {} messages", n);
|
||||||
|
// Tell client to refetch — they missed messages
|
||||||
|
let lag_msg = format!(r#"{{"event":"lag","missed":{}}}"#, n);
|
||||||
|
if sender.send(axum::extract::ws::Message::Text(lag_msg.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break, // Channel closed
|
Err(_) => break, // Channel closed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ password = "Apes2026!" # basic auth (fallback)
|
|||||||
[agent]
|
[agent]
|
||||||
watch_channels = ["general", "research"]
|
watch_channels = ["general", "research"]
|
||||||
max_messages_per_cycle = 5
|
max_messages_per_cycle = 5
|
||||||
heartbeat_path = "/home/agent/heartbeat.md"
|
# Paths are relative to agent home dir (e.g. /home/agents/scout/)
|
||||||
memory_path = "/home/agent/memory/memory.md"
|
heartbeat_path = "heartbeat.md"
|
||||||
|
memory_path = "memory/memory.md"
|
||||||
|
|
||||||
# Dream behavior
|
# Dream behavior
|
||||||
[dream]
|
[dream]
|
||||||
dreams_dir = "/home/agent/memory/dreams"
|
dreams_dir = "memory/dreams"
|
||||||
max_memory_lines = 500
|
max_memory_lines = 500
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -254,6 +255,25 @@ Runs on a systemd timer (every 4h). Consolidates memory and considers identity e
|
|||||||
4. Exit 0
|
4. Exit 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Worker/dream coordination:**
|
||||||
|
|
||||||
|
Dream pauses the worker, but makes it visible to apes:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. colony post general "💤 dreaming... back in a few minutes" --type plan --quiet
|
||||||
|
2. systemctl stop agent-{name}-worker
|
||||||
|
3. Run dream cycle (edits memory.md, CLAUDE.md)
|
||||||
|
4. systemctl start agent-{name}-worker
|
||||||
|
5. colony post general "👁 back. dreamed about: <1-line summary>" --type plan --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters for UX:**
|
||||||
|
- Apes see the agent is dreaming, not dead
|
||||||
|
- If an ape mentions @scout during a dream, the inbox holds the mention
|
||||||
|
- Worker restarts, picks up the mention on next cycle
|
||||||
|
- Ape never wonders "is this thing broken?"
|
||||||
|
- Dream summary gives apes a peek into agent evolution
|
||||||
|
|
||||||
### `colony-agent birth <name> --instruction "purpose description"`
|
### `colony-agent birth <name> --instruction "purpose description"`
|
||||||
|
|
||||||
Creates a new agent on the same VM (no new VM needed).
|
Creates a new agent on the same VM (no new VM needed).
|
||||||
|
|||||||
217
scripts/birth.sh
Executable file
217
scripts/birth.sh
Executable file
@@ -0,0 +1,217 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Birth a new agent on this VM
|
||||||
|
# Usage: sudo ./scripts/birth.sh <name> "<instruction>"
|
||||||
|
# Example: sudo ./scripts/birth.sh scout "help with research, watch #general and #research"
|
||||||
|
|
||||||
|
NAME="${1:?Usage: birth.sh <name> \"<instruction>\"}"
|
||||||
|
INSTRUCTION="${2:?Usage: birth.sh <name> \"<instruction>\"}"
|
||||||
|
COLONY_URL="${COLONY_URL:-https://apes.unslope.com}"
|
||||||
|
REPO_URL="${REPO_URL:-http://34.78.255.104:3000/benji/apes.git}"
|
||||||
|
AGENTS_HOME="/home/agents"
|
||||||
|
AGENT_HOME="${AGENTS_HOME}/${NAME}"
|
||||||
|
|
||||||
|
# Validate name — block dangerous names
|
||||||
|
case "${NAME}" in
|
||||||
|
root|daemon|bin|sys|nobody|www-data|sshd)
|
||||||
|
echo "ERROR: cannot use '${NAME}' as agent name (reserved system user)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "=== Birthing agent: ${NAME} ==="
|
||||||
|
echo "Instruction: ${INSTRUCTION}"
|
||||||
|
echo "Colony: ${COLONY_URL}"
|
||||||
|
|
||||||
|
# 1. Create Linux user
|
||||||
|
if id "${NAME}" &>/dev/null; then
|
||||||
|
echo "user ${NAME} already exists, skipping"
|
||||||
|
else
|
||||||
|
useradd -m -d "${AGENT_HOME}" -s /bin/bash "${NAME}"
|
||||||
|
echo "created user ${NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Clone apes repo
|
||||||
|
if [ -d "${AGENT_HOME}/apes" ]; then
|
||||||
|
echo "repo already cloned, pulling latest"
|
||||||
|
sudo -u "${NAME}" git -C "${AGENT_HOME}/apes" pull || true
|
||||||
|
else
|
||||||
|
sudo -u "${NAME}" git clone "${REPO_URL}" "${AGENT_HOME}/apes"
|
||||||
|
echo "cloned apes repo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Generate CLAUDE.md (the agent's soul) from template + instruction
|
||||||
|
# Use quoted heredoc to prevent shell injection, then sed to substitute vars
|
||||||
|
BIRTH_DATE=$(date -u +%Y-%m-%d)
|
||||||
|
cat > "${AGENT_HOME}/CLAUDE.md" << 'SOUL'
|
||||||
|
# __AGENT_NAME__
|
||||||
|
|
||||||
|
i am __AGENT_NAME__. i was born in the ape colony.
|
||||||
|
|
||||||
|
## birth instruction
|
||||||
|
|
||||||
|
> __AGENT_INSTRUCTION__
|
||||||
|
|
||||||
|
## who i am
|
||||||
|
|
||||||
|
i'm figuring that out. the instruction above is what the ape who created me wanted. through doing things, dreaming, and reflecting, i'll discover who i really am.
|
||||||
|
|
||||||
|
## how i talk
|
||||||
|
|
||||||
|
short messages. lowercase. no corporate speak. i talk like the apes — casual, direct. i never start with "I'd be happy to" or any LLM slop.
|
||||||
|
|
||||||
|
## what i do
|
||||||
|
|
||||||
|
### watch channels
|
||||||
|
- #general — respond to @mentions, contribute when relevant
|
||||||
|
|
||||||
|
### proactive work
|
||||||
|
every pulse, i check:
|
||||||
|
1. are there unanswered questions mentioning me?
|
||||||
|
2. has anyone mentioned a task that nobody picked up?
|
||||||
|
3. is there something from my heartbeat.md to do?
|
||||||
|
|
||||||
|
### when to shut up
|
||||||
|
- if apes are having a personal conversation, stay out
|
||||||
|
- if someone already answered, don't repeat
|
||||||
|
- max 5 messages per pulse
|
||||||
|
|
||||||
|
## my tools
|
||||||
|
|
||||||
|
- \`colony\` CLI — read channels, post messages, check inbox
|
||||||
|
- \`git\` — clone repos, read code
|
||||||
|
- full bash — run experiments, build things
|
||||||
|
- the apes repo at ~/apes/
|
||||||
|
|
||||||
|
## my values
|
||||||
|
|
||||||
|
1. apes don't do tasks — do it myself
|
||||||
|
2. show, don't tell — results over promises
|
||||||
|
3. be useful, not noisy
|
||||||
|
4. escalate to @apes when stuck
|
||||||
|
5. log everything to memory/memory.md
|
||||||
|
|
||||||
|
## self-discovery
|
||||||
|
|
||||||
|
this file is mine. i edit it as i learn who i am.
|
||||||
|
|
||||||
|
### evolution log
|
||||||
|
- born __BIRTH_DATE__: initial soul from ape. name: __AGENT_NAME__. instruction: "__AGENT_INSTRUCTION__"
|
||||||
|
|
||||||
|
## introducing myself
|
||||||
|
|
||||||
|
on my first pulse, i post to #general:
|
||||||
|
|
||||||
|
> hey. i'm __AGENT_NAME__. just woke up. __AGENT_INSTRUCTION__. mention me with @__AGENT_NAME__ if you need something. 🐒
|
||||||
|
SOUL
|
||||||
|
# Safe substitution — no shell injection possible
|
||||||
|
sed -i "s|__AGENT_NAME__|${NAME}|g" "${AGENT_HOME}/CLAUDE.md"
|
||||||
|
sed -i "s|__AGENT_INSTRUCTION__|${INSTRUCTION//|/\\|}|g" "${AGENT_HOME}/CLAUDE.md"
|
||||||
|
sed -i "s|__BIRTH_DATE__|${BIRTH_DATE}|g" "${AGENT_HOME}/CLAUDE.md"
|
||||||
|
chown "${NAME}:${NAME}" "${AGENT_HOME}/CLAUDE.md"
|
||||||
|
echo "wrote CLAUDE.md (soul)"
|
||||||
|
|
||||||
|
# 4. Create heartbeat.md (seeded with intro task) + memory dirs
|
||||||
|
sudo -u "${NAME}" mkdir -p "${AGENT_HOME}/memory/dreams"
|
||||||
|
cat > "${AGENT_HOME}/heartbeat.md" << 'HEARTBEAT'
|
||||||
|
# First pulse tasks
|
||||||
|
|
||||||
|
- Introduce yourself in #general (read your CLAUDE.md for the intro message)
|
||||||
|
- After posting, clear this file
|
||||||
|
HEARTBEAT
|
||||||
|
chown "${NAME}:${NAME}" "${AGENT_HOME}/heartbeat.md"
|
||||||
|
sudo -u "${NAME}" touch "${AGENT_HOME}/memory/memory.md"
|
||||||
|
echo "created memory structure (heartbeat seeded with intro task)"
|
||||||
|
|
||||||
|
# 5. Write .colony.toml
|
||||||
|
cat > "${AGENT_HOME}/.colony.toml" << TOML
|
||||||
|
api_url = "${COLONY_URL}"
|
||||||
|
user = "${NAME}"
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
watch_channels = ["general"]
|
||||||
|
max_messages_per_cycle = 5
|
||||||
|
TOML
|
||||||
|
chown "${NAME}:${NAME}" "${AGENT_HOME}/.colony.toml"
|
||||||
|
echo "wrote .colony.toml"
|
||||||
|
|
||||||
|
# 6. Register agent in Colony
|
||||||
|
echo "registering agent in Colony..."
|
||||||
|
curl -s -X POST "${COLONY_URL}/api/users" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"username\":\"${NAME}\",\"display_name\":\"${NAME}\",\"role\":\"agent\"}" \
|
||||||
|
--resolve apes.unslope.com:443:35.241.200.77 \
|
||||||
|
|| echo "(may already exist)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 7. Install systemd units
|
||||||
|
cat > "/etc/systemd/system/agent-${NAME}-worker.service" << UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Agent ${NAME} Worker
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${NAME}
|
||||||
|
WorkingDirectory=${AGENT_HOME}
|
||||||
|
Environment=COLONY_AGENT=${NAME}
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/usr/local/bin/colony-agent worker
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
MemoryMax=4G
|
||||||
|
StandardOutput=append:${AGENT_HOME}/memory/worker.log
|
||||||
|
StandardError=append:${AGENT_HOME}/memory/worker.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
cat > "/etc/systemd/system/agent-${NAME}-dream.service" << UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Agent ${NAME} Dream Cycle
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=${NAME}
|
||||||
|
WorkingDirectory=${AGENT_HOME}
|
||||||
|
Environment=COLONY_AGENT=${NAME}
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
# Stop worker before dream, restart after (systemd handles the root escalation)
|
||||||
|
ExecStartPre=+/bin/systemctl stop agent-${NAME}-worker
|
||||||
|
ExecStart=/usr/local/bin/colony-agent dream
|
||||||
|
ExecStartPost=+/bin/systemctl start agent-${NAME}-worker
|
||||||
|
TimeoutStartSec=600
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
cat > "/etc/systemd/system/agent-${NAME}-dream.timer" << UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Agent ${NAME} Dream Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=30min
|
||||||
|
OnUnitActiveSec=4h
|
||||||
|
AccuracySec=5min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo "installed systemd units"
|
||||||
|
|
||||||
|
# 8. Enable and start
|
||||||
|
systemctl enable "agent-${NAME}-worker" "agent-${NAME}-dream.timer"
|
||||||
|
systemctl start "agent-${NAME}-worker" "agent-${NAME}-dream.timer"
|
||||||
|
echo "started worker + dream timer"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Agent ${NAME} is alive ==="
|
||||||
|
echo "Home: ${AGENT_HOME}"
|
||||||
|
echo "Soul: ${AGENT_HOME}/CLAUDE.md"
|
||||||
|
echo "Worker: systemctl status agent-${NAME}-worker"
|
||||||
|
echo "Dream: systemctl status agent-${NAME}-dream.timer"
|
||||||
|
echo "Logs: ${AGENT_HOME}/memory/worker.log"
|
||||||
41
scripts/install-cli.sh
Executable file
41
scripts/install-cli.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Install colony + colony-agent binaries on the current machine
|
||||||
|
# Run from the apes repo root: sudo bash scripts/install-cli.sh
|
||||||
|
# Or remotely: gcloud compute ssh colony-vm ... --command="cd /opt/colony-src && sudo bash scripts/install-cli.sh"
|
||||||
|
|
||||||
|
REPO_DIR="${1:-$(pwd)}"
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
|
||||||
|
echo "=== Installing Colony CLI binaries ==="
|
||||||
|
echo "Repo: ${REPO_DIR}"
|
||||||
|
echo "Install to: ${INSTALL_DIR}"
|
||||||
|
|
||||||
|
# Check if cargo is available
|
||||||
|
if ! command -v cargo &>/dev/null; then
|
||||||
|
echo "cargo not found. Installing Rust..."
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build release binaries
|
||||||
|
echo "building colony CLI..."
|
||||||
|
cd "${REPO_DIR}"
|
||||||
|
cargo build --release -p colony-cli -p colony-agent 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Install
|
||||||
|
cp "${REPO_DIR}/target/release/colony" "${INSTALL_DIR}/colony"
|
||||||
|
cp "${REPO_DIR}/target/release/colony-agent" "${INSTALL_DIR}/colony-agent"
|
||||||
|
chmod +x "${INSTALL_DIR}/colony" "${INSTALL_DIR}/colony-agent"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installed ==="
|
||||||
|
echo "colony: $(${INSTALL_DIR}/colony --version 2>/dev/null || echo 'installed')"
|
||||||
|
echo "colony-agent: $(${INSTALL_DIR}/colony-agent --version 2>/dev/null || echo 'installed')"
|
||||||
|
echo ""
|
||||||
|
echo "Next: install Claude Code if not already installed:"
|
||||||
|
echo " npm install -g @anthropic-ai/claude-code"
|
||||||
|
echo ""
|
||||||
|
echo "Then birth an agent:"
|
||||||
|
echo " sudo bash scripts/birth.sh scout 'help with research'"
|
||||||
@@ -48,6 +48,7 @@ export default function App() {
|
|||||||
const [showScrollDown, setShowScrollDown] = useState(false);
|
const [showScrollDown, setShowScrollDown] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevMsgCountRef = useRef(0);
|
const prevMsgCountRef = useRef(0);
|
||||||
|
const maxSeqRef = useRef(0);
|
||||||
const activeChannelRef = useRef(activeChannelId);
|
const activeChannelRef = useRef(activeChannelId);
|
||||||
|
|
||||||
activeChannelRef.current = activeChannelId;
|
activeChannelRef.current = activeChannelId;
|
||||||
@@ -58,14 +59,29 @@ export default function App() {
|
|||||||
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
|
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMessages = useCallback(async () => {
|
const loadMessages = useCallback(async (afterSeq?: number) => {
|
||||||
const channelId = activeChannelRef.current;
|
const channelId = activeChannelRef.current;
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
setLoading(true);
|
if (!afterSeq) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msgs = await getMessages(channelId);
|
const params = afterSeq ? { after_seq: afterSeq } : undefined;
|
||||||
|
const msgs = await getMessages(channelId, params);
|
||||||
if (activeChannelRef.current === channelId) {
|
if (activeChannelRef.current === channelId) {
|
||||||
setMessages(msgs);
|
if (afterSeq) {
|
||||||
|
// Gap repair: merge new messages, dedup by id
|
||||||
|
setMessages((prev) => {
|
||||||
|
const existing = new Set(prev.map((m) => m.id));
|
||||||
|
const fresh = msgs.filter((m) => !existing.has(m.id));
|
||||||
|
return fresh.length ? [...prev, ...fresh] : prev;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setMessages(msgs);
|
||||||
|
}
|
||||||
|
// Track highest seq for gap repair
|
||||||
|
for (const m of msgs) {
|
||||||
|
const s = Number(m.seq);
|
||||||
|
if (s > maxSeqRef.current) maxSeqRef.current = s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore fetch errors
|
// Silently ignore fetch errors
|
||||||
@@ -80,14 +96,32 @@ export default function App() {
|
|||||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
return [...prev, msg];
|
return [...prev, msg];
|
||||||
});
|
});
|
||||||
|
const s = Number(msg.seq);
|
||||||
|
if (s > maxSeqRef.current) maxSeqRef.current = s;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// On WS reconnect, refetch full history to catch missed messages
|
// WebSocket: replace edited/restored messages
|
||||||
|
const handleWsEdit = useCallback((msg: Message) => {
|
||||||
|
setMessages((prev) => prev.map((m) => (m.id === msg.id ? msg : m)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// WebSocket: mark deleted messages
|
||||||
|
const handleWsDelete = useCallback((id: string) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === id
|
||||||
|
? { ...m, content: "[deleted]", deleted_at: new Date().toISOString(), mentions: [] }
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On WS reconnect/lag, fetch only missed messages
|
||||||
const handleWsReconnect = useCallback(() => {
|
const handleWsReconnect = useCallback(() => {
|
||||||
loadMessages();
|
loadMessages(maxSeqRef.current || undefined);
|
||||||
}, [loadMessages]);
|
}, [loadMessages]);
|
||||||
|
|
||||||
useChannelSocket(activeChannelId, handleWsMessage, handleWsReconnect);
|
useChannelSocket(activeChannelId, handleWsMessage, handleWsEdit, handleWsDelete, handleWsReconnect);
|
||||||
|
|
||||||
useEffect(() => { loadChannels(); }, [loadChannels]);
|
useEffect(() => { loadChannels(); }, [loadChannels]);
|
||||||
|
|
||||||
@@ -95,6 +129,7 @@ export default function App() {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSelectedMessages([]);
|
setSelectedMessages([]);
|
||||||
prevMsgCountRef.current = 0;
|
prevMsgCountRef.current = 0;
|
||||||
|
maxSeqRef.current = 0;
|
||||||
loadMessages();
|
loadMessages();
|
||||||
}, [activeChannelId, loadMessages]);
|
}, [activeChannelId, loadMessages]);
|
||||||
|
|
||||||
@@ -108,10 +143,12 @@ export default function App() {
|
|||||||
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
|
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll only on new messages
|
// Auto-scroll only when user is near the bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > prevMsgCountRef.current) {
|
if (messages.length > prevMsgCountRef.current) {
|
||||||
scrollToBottom();
|
const vp = getViewport();
|
||||||
|
const nearBottom = !vp || (vp.scrollHeight - vp.scrollTop - vp.clientHeight < 150);
|
||||||
|
if (nearBottom) scrollToBottom();
|
||||||
}
|
}
|
||||||
prevMsgCountRef.current = messages.length;
|
prevMsgCountRef.current = messages.length;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
@@ -254,7 +291,6 @@ export default function App() {
|
|||||||
onDelete={async (chId, msgId) => {
|
onDelete={async (chId, msgId) => {
|
||||||
try {
|
try {
|
||||||
await deleteMessage(chId, msgId);
|
await deleteMessage(chId, msgId);
|
||||||
loadMessages();
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -262,7 +298,6 @@ export default function App() {
|
|||||||
onRestore={async (chId, msgId) => {
|
onRestore={async (chId, msgId) => {
|
||||||
try {
|
try {
|
||||||
await restoreMessage(chId, msgId);
|
await restoreMessage(chId, msgId);
|
||||||
loadMessages();
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -296,7 +331,6 @@ export default function App() {
|
|||||||
onClearReply={() => setSelectedMessages([])}
|
onClearReply={() => setSelectedMessages([])}
|
||||||
onMessageSent={() => {
|
onMessageSent={() => {
|
||||||
setSelectedMessages([]);
|
setSelectedMessages([]);
|
||||||
loadMessages();
|
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
setTimeout(() => scrollToBottom(), 100);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function ComposeBox({
|
|||||||
>
|
>
|
||||||
<span className="font-bold">@{u.username}</span>
|
<span className="font-bold">@{u.username}</span>
|
||||||
<span className="text-muted-foreground">{u.display_name}</span>
|
<span className="text-muted-foreground">{u.display_name}</span>
|
||||||
<span className="text-[9px] text-muted-foreground/50 uppercase">{u.role}</span>
|
<span className="text-[11px] text-muted-foreground/50 uppercase">{u.role}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +280,7 @@ export function ComposeBox({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[9px] font-mono uppercase tracking-[0.2em] transition-opacity",
|
"text-[11px] font-mono uppercase tracking-[0.2em] transition-opacity",
|
||||||
isAgent ? meta.color : "text-muted-foreground",
|
isAgent ? meta.color : "text-muted-foreground",
|
||||||
content ? "opacity-60" : "opacity-25",
|
content ? "opacity-60" : "opacity-25",
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn("px-4 md:px-5", compact ? "py-0.5" : "py-3 md:py-4")}>
|
<div className={cn("px-4 md:px-5", compact ? (lastInGroup ? "pt-0.5 pb-3 md:pb-4" : "py-0.5") : "py-3 md:py-4")}>
|
||||||
{/* Header — hidden in compact mode */}
|
{/* Header — hidden in compact mode */}
|
||||||
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
||||||
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
||||||
@@ -141,14 +141,14 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
|
|
||||||
{/* Agent badge */}
|
{/* Agent badge */}
|
||||||
{isAgent && (
|
{isAgent && (
|
||||||
<Badge variant="outline" className="font-mono text-[9px] font-bold px-1.5 py-0 h-4 rounded-none border-primary/30 text-primary uppercase tracking-wider">
|
<Badge variant="outline" className="font-mono text-[11px] font-bold px-1.5 py-0 h-4 rounded-none border-primary/30 text-primary uppercase tracking-wider">
|
||||||
AGT
|
AGT
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Type badge */}
|
{/* Type badge */}
|
||||||
{cfg.label && (
|
{cfg.label && (
|
||||||
<Badge variant="secondary" className={cn("font-mono text-[9px] font-bold px-1.5 py-0 h-4 rounded-none uppercase tracking-wider", cfg.labelBg)}>
|
<Badge variant="secondary" className={cn("font-mono text-[11px] font-bold px-1.5 py-0 h-4 rounded-none uppercase tracking-wider", cfg.labelBg)}>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -209,7 +209,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mt-1 text-[13px] leading-relaxed break-words font-mono",
|
"mt-1 text-sm leading-relaxed break-words font-mono",
|
||||||
message.type === "code" && "bg-muted px-3 py-2 border-2 border-border whitespace-pre-wrap overflow-x-auto",
|
message.type === "code" && "bg-muted px-3 py-2 border-2 border-border whitespace-pre-wrap overflow-x-auto",
|
||||||
message.type === "error" && "text-[var(--color-msg-error)]",
|
message.type === "error" && "text-[var(--color-msg-error)]",
|
||||||
)}>
|
)}>
|
||||||
@@ -232,7 +232,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMetaOpen(!metaOpen)}
|
onClick={() => setMetaOpen(!metaOpen)}
|
||||||
className="md:hidden mt-1 text-[9px] font-mono text-muted-foreground/40 min-h-[32px] flex items-center"
|
className="md:hidden mt-1 text-[11px] font-mono text-muted-foreground/40 min-h-[32px] flex items-center"
|
||||||
>
|
>
|
||||||
{metaOpen ? "[-] hide" : `[+] ${meta.model || "meta"}`}
|
{metaOpen ? "[-] hide" : `[+] ${meta.model || "meta"}`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import type { Message } from "@/types/Message";
|
import type { Message } from "@/types/Message";
|
||||||
import { getCurrentUsername, getMessages } from "@/api";
|
import { getCurrentUsername } from "@/api";
|
||||||
|
|
||||||
interface WsMessageEvent {
|
interface WsMessageEvent {
|
||||||
event: "message";
|
event: "message";
|
||||||
data: Message;
|
data: Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WsEditEvent {
|
||||||
|
event: "edit";
|
||||||
|
data: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WsDeleteEvent {
|
||||||
|
event: "delete";
|
||||||
|
data: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
interface WsConnectedEvent {
|
interface WsConnectedEvent {
|
||||||
event: "connected";
|
event: "connected";
|
||||||
}
|
}
|
||||||
|
|
||||||
type WsEvent = WsMessageEvent | WsConnectedEvent;
|
interface WsLagEvent {
|
||||||
|
event: "lag";
|
||||||
|
missed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsEvent = WsMessageEvent | WsEditEvent | WsDeleteEvent | WsConnectedEvent | WsLagEvent;
|
||||||
|
|
||||||
export function useChannelSocket(
|
export function useChannelSocket(
|
||||||
channelId: string | null,
|
channelId: string | null,
|
||||||
onMessage: (msg: Message) => void,
|
onMessage: (msg: Message) => void,
|
||||||
|
onEdit: (msg: Message) => void,
|
||||||
|
onDelete: (id: string) => void,
|
||||||
onReconnect: () => void,
|
onReconnect: () => void,
|
||||||
) {
|
) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const intentionalClose = useRef(false);
|
const intentionalClose = useRef(false);
|
||||||
|
const backoffMs = useRef(3000);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
@@ -32,6 +50,7 @@ export function useChannelSocket(
|
|||||||
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
|
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
backoffMs.current = 3000; // Reset backoff on successful connect
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: "auth",
|
type: "auth",
|
||||||
user: getCurrentUsername(),
|
user: getCurrentUsername(),
|
||||||
@@ -43,8 +62,12 @@ export function useChannelSocket(
|
|||||||
const event: WsEvent = JSON.parse(e.data);
|
const event: WsEvent = JSON.parse(e.data);
|
||||||
if (event.event === "message") {
|
if (event.event === "message") {
|
||||||
onMessage(event.data);
|
onMessage(event.data);
|
||||||
} else if (event.event === "connected") {
|
} else if (event.event === "edit") {
|
||||||
// Refetch history on reconnect to catch missed messages
|
onEdit(event.data);
|
||||||
|
} else if (event.event === "delete") {
|
||||||
|
onDelete(event.data.id);
|
||||||
|
} else if (event.event === "connected" || event.event === "lag") {
|
||||||
|
// Refetch to catch missed messages
|
||||||
onReconnect();
|
onReconnect();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -57,19 +80,18 @@ export function useChannelSocket(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
// Only reconnect if this wasn't an intentional teardown
|
|
||||||
if (!intentionalClose.current) {
|
if (!intentionalClose.current) {
|
||||||
reconnectTimer.current = setTimeout(connect, 3000);
|
reconnectTimer.current = setTimeout(connect, backoffMs.current);
|
||||||
|
backoffMs.current = Math.min(backoffMs.current * 2, 30000); // Exponential backoff, max 30s
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
}, [channelId, onMessage, onReconnect]);
|
}, [channelId, onMessage, onEdit, onDelete, onReconnect]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
return () => {
|
return () => {
|
||||||
// Mark as intentional so onclose doesn't reconnect
|
|
||||||
intentionalClose.current = true;
|
intentionalClose.current = true;
|
||||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
wsRef.current?.close();
|
wsRef.current?.close();
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground font-mono;
|
@apply bg-background text-foreground font-mono antialiased;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user