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>"'
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
| Error | Fix |
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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 ──
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct CreateChannel {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[derive(Debug, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct PostMessage {
|
||||
pub content: String,
|
||||
@@ -97,6 +97,28 @@ pub enum WsEvent {
|
||||
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 ──
|
||||
|
||||
#[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;
|
||||
|
||||
/// Extract @mentions from message content
|
||||
fn parse_mentions(content: &str) -> Vec<String> {
|
||||
pub fn parse_mentions(content: &str) -> Vec<String> {
|
||||
content
|
||||
.split_whitespace()
|
||||
.filter(|w| w.starts_with('@') && w.len() > 1)
|
||||
@@ -97,7 +97,11 @@ impl MessageRow {
|
||||
} else {
|
||||
self.content.clone()
|
||||
},
|
||||
mentions: parse_mentions(&self.content),
|
||||
mentions: if self.deleted_at.is_some() {
|
||||
vec![]
|
||||
} else {
|
||||
parse_mentions(&self.content)
|
||||
},
|
||||
metadata: self
|
||||
.metadata
|
||||
.as_ref()
|
||||
|
||||
@@ -4,9 +4,10 @@ mod state;
|
||||
mod ws;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use state::AppState;
|
||||
use std::env;
|
||||
use std::str::FromStr;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
#[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 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()
|
||||
.max_connections(5)
|
||||
.connect(&db_url)
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
eprintln!("colony: connected to {}", db_url);
|
||||
|
||||
sqlx::query("PRAGMA journal_mode=WAL")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
@@ -38,7 +40,7 @@ async fn main() {
|
||||
|
||||
let app = Router::new()
|
||||
.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/channels",
|
||||
@@ -57,6 +59,8 @@ async fn main() {
|
||||
"/api/channels/{channel_id}/messages/{msg_id}/restore",
|
||||
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))
|
||||
.fallback_service(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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 user_id = resolve_user(&state.db, &user_param).await?;
|
||||
|
||||
@@ -252,7 +290,7 @@ pub async fn post_message(
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&channel_id)
|
||||
.bind(user_id)
|
||||
.bind(&user_id)
|
||||
.bind(msg_type)
|
||||
.bind(&body.content)
|
||||
.bind(&metadata_json)
|
||||
@@ -275,6 +313,9 @@ pub async fn post_message(
|
||||
let tx = state.get_sender(&channel_id).await;
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -347,13 +388,184 @@ pub async fn restore_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.send(WsEvent::Message(message.clone()));
|
||||
let _ = tx.send(WsEvent::Edit(message.clone()));
|
||||
|
||||
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 ──
|
||||
|
||||
#[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)) => {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -64,12 +64,13 @@ password = "Apes2026!" # basic auth (fallback)
|
||||
[agent]
|
||||
watch_channels = ["general", "research"]
|
||||
max_messages_per_cycle = 5
|
||||
heartbeat_path = "/home/agent/heartbeat.md"
|
||||
memory_path = "/home/agent/memory/memory.md"
|
||||
# Paths are relative to agent home dir (e.g. /home/agents/scout/)
|
||||
heartbeat_path = "heartbeat.md"
|
||||
memory_path = "memory/memory.md"
|
||||
|
||||
# Dream behavior
|
||||
[dream]
|
||||
dreams_dir = "/home/agent/memory/dreams"
|
||||
dreams_dir = "memory/dreams"
|
||||
max_memory_lines = 500
|
||||
```
|
||||
|
||||
@@ -254,6 +255,25 @@ Runs on a systemd timer (every 4h). Consolidates memory and considers identity e
|
||||
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"`
|
||||
|
||||
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 scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevMsgCountRef = useRef(0);
|
||||
const maxSeqRef = useRef(0);
|
||||
const activeChannelRef = useRef(activeChannelId);
|
||||
|
||||
activeChannelRef.current = activeChannelId;
|
||||
@@ -58,14 +59,29 @@ export default function App() {
|
||||
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
|
||||
}, []);
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
const loadMessages = useCallback(async (afterSeq?: number) => {
|
||||
const channelId = activeChannelRef.current;
|
||||
if (!channelId) return;
|
||||
setLoading(true);
|
||||
if (!afterSeq) setLoading(true);
|
||||
try {
|
||||
const msgs = await getMessages(channelId);
|
||||
const params = afterSeq ? { after_seq: afterSeq } : undefined;
|
||||
const msgs = await getMessages(channelId, params);
|
||||
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 {
|
||||
// Silently ignore fetch errors
|
||||
@@ -80,14 +96,32 @@ export default function App() {
|
||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||
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(() => {
|
||||
loadMessages();
|
||||
loadMessages(maxSeqRef.current || undefined);
|
||||
}, [loadMessages]);
|
||||
|
||||
useChannelSocket(activeChannelId, handleWsMessage, handleWsReconnect);
|
||||
useChannelSocket(activeChannelId, handleWsMessage, handleWsEdit, handleWsDelete, handleWsReconnect);
|
||||
|
||||
useEffect(() => { loadChannels(); }, [loadChannels]);
|
||||
|
||||
@@ -95,6 +129,7 @@ export default function App() {
|
||||
setMessages([]);
|
||||
setSelectedMessages([]);
|
||||
prevMsgCountRef.current = 0;
|
||||
maxSeqRef.current = 0;
|
||||
loadMessages();
|
||||
}, [activeChannelId, loadMessages]);
|
||||
|
||||
@@ -108,10 +143,12 @@ export default function App() {
|
||||
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(() => {
|
||||
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;
|
||||
}, [messages]);
|
||||
@@ -254,7 +291,6 @@ export default function App() {
|
||||
onDelete={async (chId, msgId) => {
|
||||
try {
|
||||
await deleteMessage(chId, msgId);
|
||||
loadMessages();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -262,7 +298,6 @@ export default function App() {
|
||||
onRestore={async (chId, msgId) => {
|
||||
try {
|
||||
await restoreMessage(chId, msgId);
|
||||
loadMessages();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -296,7 +331,6 @@ export default function App() {
|
||||
onClearReply={() => setSelectedMessages([])}
|
||||
onMessageSent={() => {
|
||||
setSelectedMessages([]);
|
||||
loadMessages();
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -187,7 +187,7 @@ export function ComposeBox({
|
||||
>
|
||||
<span className="font-bold">@{u.username}</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>
|
||||
))}
|
||||
</div>
|
||||
@@ -280,7 +280,7 @@ export function ComposeBox({
|
||||
)}
|
||||
|
||||
<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",
|
||||
content ? "opacity-60" : "opacity-25",
|
||||
)}>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
||||
</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 */}
|
||||
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
||||
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
||||
@@ -141,14 +141,14 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
||||
|
||||
{/* Agent badge */}
|
||||
{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
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Type badge */}
|
||||
{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}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -209,7 +209,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
||||
|
||||
{/* Content */}
|
||||
<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 === "error" && "text-[var(--color-msg-error)]",
|
||||
)}>
|
||||
@@ -232,7 +232,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
||||
<button
|
||||
type="button"
|
||||
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"}`}
|
||||
</button>
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { Message } from "@/types/Message";
|
||||
import { getCurrentUsername, getMessages } from "@/api";
|
||||
import { getCurrentUsername } from "@/api";
|
||||
|
||||
interface WsMessageEvent {
|
||||
event: "message";
|
||||
data: Message;
|
||||
}
|
||||
|
||||
interface WsEditEvent {
|
||||
event: "edit";
|
||||
data: Message;
|
||||
}
|
||||
|
||||
interface WsDeleteEvent {
|
||||
event: "delete";
|
||||
data: { id: string };
|
||||
}
|
||||
|
||||
interface WsConnectedEvent {
|
||||
event: "connected";
|
||||
}
|
||||
|
||||
type WsEvent = WsMessageEvent | WsConnectedEvent;
|
||||
interface WsLagEvent {
|
||||
event: "lag";
|
||||
missed: number;
|
||||
}
|
||||
|
||||
type WsEvent = WsMessageEvent | WsEditEvent | WsDeleteEvent | WsConnectedEvent | WsLagEvent;
|
||||
|
||||
export function useChannelSocket(
|
||||
channelId: string | null,
|
||||
onMessage: (msg: Message) => void,
|
||||
onEdit: (msg: Message) => void,
|
||||
onDelete: (id: string) => void,
|
||||
onReconnect: () => void,
|
||||
) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
const backoffMs = useRef(3000);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!channelId) return;
|
||||
@@ -32,6 +50,7 @@ export function useChannelSocket(
|
||||
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
backoffMs.current = 3000; // Reset backoff on successful connect
|
||||
ws.send(JSON.stringify({
|
||||
type: "auth",
|
||||
user: getCurrentUsername(),
|
||||
@@ -43,8 +62,12 @@ export function useChannelSocket(
|
||||
const event: WsEvent = JSON.parse(e.data);
|
||||
if (event.event === "message") {
|
||||
onMessage(event.data);
|
||||
} else if (event.event === "connected") {
|
||||
// Refetch history on reconnect to catch missed messages
|
||||
} else if (event.event === "edit") {
|
||||
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();
|
||||
}
|
||||
} catch {
|
||||
@@ -57,19 +80,18 @@ export function useChannelSocket(
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Only reconnect if this wasn't an intentional teardown
|
||||
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;
|
||||
}, [channelId, onMessage, onReconnect]);
|
||||
}, [channelId, onMessage, onEdit, onDelete, onReconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
// Mark as intentional so onclose doesn't reconnect
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
wsRef.current?.close();
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-mono;
|
||||
@apply bg-background text-foreground font-mono antialiased;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user