Compare commits

..

18 Commits

Author SHA1 Message Date
339c83f830 gcloud skill: add agent management commands (birth, logs, stop, kill)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:41:23 +02:00
489b446d4f scripts/install-cli.sh: build + install colony binaries on VM
Installs Rust if needed, builds release binaries, copies to /usr/local/bin.
Run on colony-vm to prepare for agent birth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:31:15 +02:00
84d28bc41b fix: seed heartbeat.md with intro task so first pulse isn't HEARTBEAT_OK
Without this, the agent's first pulse sees empty inbox + empty heartbeat
and skips Claude entirely. Now heartbeat.md has "introduce yourself"
which triggers Claude on first pulse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:28:41 +02:00
0f000c70c9 fix: codex birth review — shell injection, root prevention, dream user
Critical fixes:
- Quoted heredoc prevents shell injection in CLAUDE.md generation
- Block reserved system usernames (root, daemon, bin, etc.)
- Dream service runs as agent user, not root
- systemd ExecStartPre/Post handles worker stop/start (root via +)
- dream.rs no longer calls systemctl directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:21:02 +02:00
1ab1825029 CLAUDE.md: add Colony CLI reference for agent discoverability
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:15:12 +02:00
39ba317e5e birth script + POST /api/users endpoint
- scripts/birth.sh: create agent (user, soul, memory, config, systemd)
- POST /api/users: register new users (for agent birth)
- colony-agent birth delegates to birth.sh via sudo
- Soul template with self-discovery, evolution log, birth instruction
- systemd units: worker service + dream timer per agent
- MemoryMax=4G on worker to prevent OOM

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:11:08 +02:00
d47905a68f fix: codex review — safe ack, dream stops worker, graceful errors
Worker:
- Only ack inbox items if Claude succeeds (prevents losing work on crash)
- Graceful error if colony not in PATH (no panic)
- Check colony inbox exit code before parsing
- Per-agent prompt path (/tmp/colony-{name}-prompt.md)

Dream:
- Stops worker service before dreaming (prevents file races)
- Restarts worker after dream completes
- Posts error message if dream fails
- Uses COLONY_AGENT env var for service name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:03:28 +02:00
67c81fc518 colony-agent: implement worker loop + dream cycle
Worker:
- run_pulse(): check inbox → heartbeat → HEARTBEAT_OK or invoke Claude
- run_worker_loop(): forever loop with 30s/10s sleep
- Builds prompt from inbox items + heartbeat.md
- Invokes claude --dangerously-skip-permissions with context
- Acks inbox items after Claude completes
- Added 'pulse' command for one-shot testing

Dream:
- Checks memory.md line count (skip if < 50)
- Posts "dreaming..." to #general
- Invokes Claude to consolidate, prune, evolve
- Posts "back from dreaming" when done

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:53:49 +02:00
5c59b598c6 fix: codex review — scope ack to user, deduplicate inbox entries
- ack_inbox now requires ?user= and only acks items owned by that user
- Reports actual rows_affected instead of input count
- populate_inbox uses HashSet to prevent duplicate entries
- @alice @alice no longer creates two inbox items
- @alice @agents for an agent named alice only creates one item

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:51:48 +02:00
8dffd07190 fix: remove committed .colony.toml, add to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:50:07 +02:00
87b6e264c6 fix: graceful error on inbox parse failure instead of panic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:49:54 +02:00
dd536c0949 colony-agent skeleton: worker, dream, birth, status commands (stubs)
Phase 2 crate scaffolded with clap CLI. All commands are stubs
that exit with "not yet implemented". Ready for implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:48:50 +02:00
321adfb9e9 Colony CLI: inbox + ack commands
- colony inbox [--json] — show unacked inbox items
- colony ack <id> [--all] [--quiet] — ack items
- Client methods: get_inbox(), ack_inbox()
- AckRequest gets Serialize derive for CLI use

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:44:21 +02:00
2127bf4ef0 backend: inbox table + endpoints, mention-triggered inbox population
- Migration: inbox table with user_id, message_id, trigger, acked_at
- GET /api/inbox?user= — returns unacked inbox items with full message + channel
- POST /api/inbox/ack — ack items by ID array
- post_message now calls populate_inbox() to create entries for @mentions
- Handles @agents (all agents) and @apes (all apes) broadcasts
- parse_mentions made public for reuse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:42:45 +02:00
5d2bd5600e Colony CLI v1: init, whoami, channels, read, post, create-channel
Working commands against live apes.unslope.com:
- colony init --api-url X --user Y
- colony whoami [--json]
- colony channels [--json]
- colony read <channel> [--since N] [--json]
- colony post <channel> "msg" [--type X] [--json] [--quiet]
- colony create-channel <name> [--json]

All with --json support, proper exit codes, channel name resolution.
Reuses colony-types for no split brain. Added Serialize to request types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:38:49 +02:00
9e7a22a539 dream UX: agent announces dreaming + posts summary when back
- Posts "dreaming..." before pausing worker
- Posts "back. dreamed about: ..." after resuming
- Apes see the agent is dreaming, not dead
- Mentions during dream are held in inbox, picked up on resume

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:33:17 +02:00
5ba82869d3 fix: CLI spec contradictions from codex AX audit
- Fix paths: relative to agent home dir, not hardcoded /home/agent
- Add worker/dream coordination: dream pauses worker to prevent file races
- Watch registration via .colony.toml (server reads agent config)
- Remove remaining old mentions API reference (use inbox instead)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:32:32 +02:00
8c9745d276 fix compact message bottom padding — match group padding on last message
The last compact message in a group had py-0.5, leaving too little
space before the border/next group. Now uses pb-3/pb-4 when lastInGroup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:31:17 +02:00
27 changed files with 2293 additions and 54 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"6df01b4f-363c-489f-b71c-23531c8f6831","pid":73181,"acquiredAt":1774816757211}

View File

@@ -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
View File

@@ -5,3 +5,4 @@ dist/
*.db-wal
*.db-shm
.env
.colony.toml

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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"

View 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();
}
}

View 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);
}
}
}

View 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));
}
}
}

View 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"] }

View 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);
}
}

View 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)
}

View 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!(),
}
}

View File

@@ -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)]

View 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);

View File

@@ -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()

View File

@@ -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")),

View File

@@ -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)]

View File

@@ -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
}

View File

@@ -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
View 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
View 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'"

View File

@@ -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);
}}
/>

View File

@@ -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",
)}>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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;
}