Compare commits

..

20 Commits

Author SHA1 Message Date
75864eaf3d colony ssh: SSH into colony-vm from the CLI, no bash scripts
- colony ssh — runs gcloud compute ssh under the hood
- Removed scripts/ssh-vm.sh (bash scripts bad, Rust CLI good)
- CLAUDE.md: "Avoid bash scripts — put commands in the Rust CLI instead"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:19:58 +02:00
ad5595e06f scripts/ssh-vm.sh: quick SSH into colony-vm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:17:05 +02:00
e0b93ab141 paginated messages: load latest 100, scroll-up for older
Backend:
- Add before_seq + limit params to list_messages
- When limit set without after_seq, ORDER BY DESC then reverse (gets latest page)
- Reject after_seq + before_seq together (400)
- Cap limit at 1000, no default change (CLI compat)

Frontend:
- Initial load fetches ?limit=100, scrolls to bottom
- Scroll near top triggers ?before_seq=lowestSeq&limit=100
- useLayoutEffect maintains scroll position after prepend
- Gap repair loops after_seq fetches until caught up
- Auto-scroll only when near bottom (doesn't yank while reading)
- "loading older..." and "beginning of conversation" indicators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:47:03 +02:00
73696bc58c use native Claude Code installer (curl | bash), check prerequisites in birth
- install-cli.sh: suggest curl -sL https://claude.ai/install.sh | bash
- birth.sh: check colony, colony-agent, claude are in PATH before starting
- Fail fast with clear error messages if anything is missing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:27:32 +02:00
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
30 changed files with 2446 additions and 112 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
.claude/.DS_Store vendored Normal file

Binary file not shown.

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

@@ -29,7 +29,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **GCP project is `apes-platform`.** Always pass `--project=apes-platform`.
- **Region is `europe-west1`.** Zone `europe-west1-b` unless there's a reason to change.
- **No split brains.** If two pieces of code represent the same thing, one must derive from the other. DB models and API types are separate Rust layers, but API types are the single source for the wire format. TypeScript types are generated from API types via `ts-rs`. SQL migrations are canonical for the DB. Never hand-write a type that can be derived.
- **Rust first.** Backend code is Rust. Type safety everywhere. If it compiles, it should work.
- **Rust first.** Backend code is Rust. Type safety everywhere. If it compiles, it should work. Avoid bash scripts — put commands in the Rust CLI instead.
- **Always push.** After committing, push to origin immediately. Never ask the ape "should I push?" — just do it. Handle auth, firewall, DNS issues yourself.
- **Document decisions.** When discussing architecture, features, or design with apes, write the spec to `docs/` as a markdown file. Conversations are ephemeral, docs persist.
@@ -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

BIN
crates/.DS_Store vendored Normal file

Binary file not shown.

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,241 @@
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,
},
/// SSH into colony-vm
Ssh,
/// 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::Ssh => {
let status = std::process::Command::new("gcloud")
.args(["compute", "ssh", "colony-vm", "--zone=europe-west1-b", "--project=apes-platform"])
.status();
match status {
Ok(s) => std::process::exit(s.code().unwrap_or(1)),
Err(e) => { eprintln!("failed to run gcloud: {}", e); std::process::exit(1); }
}
}
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::Ssh => unreachable!(),
Commands::Init { .. } => unreachable!(),
}
}

View File

@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Message } from "./Message";
export type InboxItem = { id: bigint, message: Message, channel_name: string, trigger: string, created_at: string, };

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,11 +97,35 @@ 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)]
pub struct MessageQuery {
pub after_seq: Option<i64>,
pub before_seq: Option<i64>,
pub limit: Option<i64>,
pub r#type: Option<MessageType>,
pub user_id: Option<Uuid>,
}

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)

View File

@@ -40,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",
@@ -59,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>,
@@ -153,6 +186,11 @@ pub async fn list_messages(
Path(channel_id): Path<String>,
Query(query): Query<MessageQuery>,
) -> Result<Json<Vec<Message>>> {
// Reject conflicting cursors
if query.after_seq.is_some() && query.before_seq.is_some() {
return Err(AppError::BadRequest("Cannot use both after_seq and before_seq".into()));
}
let mut sql = String::from(
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
FROM messages m JOIN users u ON m.user_id = u.id \
@@ -160,10 +198,16 @@ pub async fn list_messages(
);
let mut binds: Vec<String> = vec![channel_id.clone()];
// Cursor filtering
if let Some(after_seq) = &query.after_seq {
sql.push_str(" AND m.seq > ?");
binds.push(after_seq.to_string());
}
if let Some(before_seq) = &query.before_seq {
sql.push_str(" AND m.seq < ?");
binds.push(before_seq.to_string());
}
if let Some(msg_type) = &query.r#type {
sql.push_str(" AND m.type = ?");
binds.push(match msg_type {
@@ -180,7 +224,21 @@ pub async fn list_messages(
binds.push(user_id.to_string());
}
// When limit is set without after_seq, fetch the LATEST messages
// (ORDER BY DESC LIMIT N, then reverse to chronological order)
let use_desc = query.limit.is_some() && query.after_seq.is_none();
if use_desc {
sql.push_str(" ORDER BY m.seq DESC");
} else {
sql.push_str(" ORDER BY m.seq ASC");
}
// Apply limit (capped at 1000)
if let Some(limit) = &query.limit {
let capped = (*limit).min(1000).max(1);
sql.push_str(&format!(" LIMIT {}", capped));
}
let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql);
for b in &binds {
@@ -188,7 +246,13 @@ pub async fn list_messages(
}
let rows = q.fetch_all(&state.db).await?;
let messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
let mut messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
// Reverse DESC results back to chronological order
if use_desc {
messages.reverse();
}
Ok(Json(messages))
}
@@ -257,7 +321,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)
@@ -280,6 +344,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)))
}
@@ -359,6 +426,177 @@ pub async fn restore_message(
Ok(Json(message))
}
// ── Inbox ──
pub async fn get_inbox(
State(state): State<AppState>,
Query(query): Query<InboxQuery>,
) -> Result<Json<Vec<InboxItem>>> {
let user_id = resolve_user(&state.db, &UserParam { user: Some(query.user) }).await?;
let rows = sqlx::query_as::<_, InboxRow>(
"SELECT i.id as inbox_id, i.message_id, i.channel_id as inbox_channel_id, i.trigger, i.created_at as i_created_at,
m.id as msg_id, m.seq, m.channel_id as msg_channel_id, m.user_id, m.type, m.content, m.metadata, m.reply_to, m.created_at as msg_created_at, m.updated_at, m.deleted_at,
u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at,
c.name as channel_name
FROM inbox i
JOIN messages m ON i.message_id = m.id
JOIN users u ON m.user_id = u.id
JOIN channels c ON i.channel_id = c.id
WHERE i.user_id = ? AND i.acked_at IS NULL
ORDER BY i.id ASC",
)
.bind(&user_id)
.fetch_all(&state.db)
.await?;
let items: Vec<InboxItem> = rows.iter().map(|r| r.to_api()).collect();
Ok(Json(items))
}
pub async fn ack_inbox(
State(state): State<AppState>,
Query(user_param): Query<UserParam>,
Json(body): Json<AckRequest>,
) -> Result<impl IntoResponse> {
let user_id = resolve_user(&state.db, &user_param).await?;
let mut acked = 0i64;
for id in &body.ids {
let result = sqlx::query("UPDATE inbox SET acked_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND user_id = ? AND acked_at IS NULL")
.bind(id)
.bind(&user_id)
.execute(&state.db)
.await?;
acked += result.rows_affected() as i64;
}
Ok(Json(serde_json::json!({"acked": acked})))
}
/// Populate inbox entries when a message is posted
async fn populate_inbox(db: &SqlitePool, message_id: &str, channel_id: &str, content: &str, sender_id: &str) {
let mentions = crate::db::parse_mentions(content);
let mut notified: std::collections::HashSet<String> = std::collections::HashSet::new();
for mention in &mentions {
// Resolve mentioned user
if let Ok(Some(uid)) = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE username = ?")
.bind(mention)
.fetch_optional(db)
.await
{
if uid != sender_id && notified.insert(uid.clone()) {
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'mention')")
.bind(&uid)
.bind(message_id)
.bind(channel_id)
.execute(db)
.await;
}
}
// Handle @agents broadcast
if mention == "agents" {
let agents = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE role = 'agent' AND id != ?")
.bind(sender_id)
.fetch_all(db)
.await
.unwrap_or_default();
for agent_id in agents {
if notified.insert(agent_id.clone()) {
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')")
.bind(&agent_id)
.bind(message_id)
.bind(channel_id)
.execute(db)
.await;
}
}
}
// Handle @apes broadcast
if mention == "apes" {
let apes = sqlx::query_scalar::<_, String>("SELECT id FROM users WHERE role = 'ape' AND id != ?")
.bind(sender_id)
.fetch_all(db)
.await
.unwrap_or_default();
for ape_id in apes {
if notified.insert(ape_id.clone()) {
let _ = sqlx::query("INSERT INTO inbox (user_id, message_id, channel_id, trigger) VALUES (?, ?, ?, 'broadcast')")
.bind(&ape_id)
.bind(message_id)
.bind(channel_id)
.execute(db)
.await;
}
}
}
}
}
// ── Inbox row type ──
#[derive(Debug, sqlx::FromRow)]
pub struct InboxRow {
pub inbox_id: i64,
pub message_id: String,
pub inbox_channel_id: String,
pub trigger: String,
pub i_created_at: String,
pub channel_name: String,
// message fields
pub msg_id: String,
pub seq: i64,
pub msg_channel_id: String,
pub user_id: String,
pub r#type: String,
pub content: String,
pub metadata: Option<String>,
pub reply_to: Option<String>,
pub msg_created_at: String,
pub updated_at: Option<String>,
pub deleted_at: Option<String>,
// user fields
pub u_id: String,
pub username: String,
pub display_name: String,
pub role: String,
pub u_created_at: String,
}
impl InboxRow {
pub fn to_api(&self) -> InboxItem {
let user_row = UserRow {
id: self.u_id.clone(),
username: self.username.clone(),
display_name: self.display_name.clone(),
role: self.role.clone(),
password_hash: None,
created_at: self.u_created_at.clone(),
};
let msg_row = MessageRow {
id: self.msg_id.clone(),
seq: self.seq,
channel_id: self.msg_channel_id.clone(),
user_id: self.user_id.clone(),
r#type: self.r#type.clone(),
content: self.content.clone(),
metadata: self.metadata.clone(),
reply_to: self.reply_to.clone(),
created_at: self.msg_created_at.clone(),
updated_at: self.updated_at.clone(),
deleted_at: self.deleted_at.clone(),
};
InboxItem {
id: self.inbox_id,
message: msg_row.to_api(&user_row),
channel_name: self.channel_name.clone(),
trigger: self.trigger.clone(),
created_at: self.i_created_at.parse().unwrap(),
}
}
}
// ── Joined row type for message + user ──
#[derive(Debug, sqlx::FromRow)]

View File

@@ -255,12 +255,24 @@ Runs on a systemd timer (every 4h). Consolidates memory and considers identity e
4. Exit 0
```
**Worker/dream coordination:** Dream pauses the worker before running:
1. `systemctl stop agent-{name}-worker`
2. Run dream cycle (edits memory.md, CLAUDE.md)
3. `systemctl start agent-{name}-worker`
**Worker/dream coordination:**
This prevents race conditions on shared files.
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"`

229
scripts/birth.sh Executable file
View File

@@ -0,0 +1,229 @@
#!/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
# Check prerequisites
for cmd in colony colony-agent; do
if ! command -v "${cmd}" &>/dev/null; then
echo "ERROR: ${cmd} not found. Run: sudo bash scripts/install-cli.sh"
exit 1
fi
done
if ! command -v claude &>/dev/null; then
echo "ERROR: claude not found. Install with: curl -sL https://claude.ai/install.sh | bash"
exit 1
fi
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 " curl -sL https://claude.ai/install.sh | bash"
echo ""
echo "Then birth an agent:"
echo " sudo bash scripts/birth.sh scout 'help with research'"

3
scripts/ssh-vm.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
# Quick SSH into colony-vm
gcloud compute ssh colony-vm --zone=europe-west1-b --project=apes-platform "$@"

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Channel } from "@/types/Channel";
import type { Message } from "@/types/Message";
import { getChannels, getMessages, getCurrentUsername, deleteMessage, restoreMessage } from "@/api";
@@ -43,95 +43,22 @@ export default function App() {
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMoreBefore, setHasMoreBefore] = useState(true);
const [selectedMessages, setSelectedMessages] = useState<{ id: string; username: string; content: string }[]>([]);
const [sheetOpen, setSheetOpen] = useState(false);
const [showScrollDown, setShowScrollDown] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const prevMsgCountRef = useRef(0);
const maxSeqRef = useRef(0);
const initialLoadRef = useRef(true);
const pendingPrependRef = useRef(false);
const prependScrollHeightRef = useRef(0);
const activeChannelRef = useRef(activeChannelId);
activeChannelRef.current = activeChannelId;
const loadChannels = useCallback(async () => {
const chs = await getChannels();
setChannels(chs);
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
}, []);
const loadMessages = useCallback(async (afterSeq?: number) => {
const channelId = activeChannelRef.current;
if (!channelId) return;
if (!afterSeq) setLoading(true);
try {
const params = afterSeq ? { after_seq: afterSeq } : undefined;
const msgs = await getMessages(channelId, params);
if (activeChannelRef.current === channelId) {
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
} finally {
setLoading(false);
}
}, []);
// WebSocket: append new messages in real-time
const handleWsMessage = useCallback((msg: Message) => {
setMessages((prev) => {
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;
}, []);
// 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(maxSeqRef.current || undefined);
}, [loadMessages]);
useChannelSocket(activeChannelId, handleWsMessage, handleWsEdit, handleWsDelete, handleWsReconnect);
useEffect(() => { loadChannels(); }, [loadChannels]);
useEffect(() => {
setMessages([]);
setSelectedMessages([]);
prevMsgCountRef.current = 0;
maxSeqRef.current = 0;
loadMessages();
}, [activeChannelId, loadMessages]);
const PAGE_SIZE = 100;
function getViewport() {
const el = scrollRef.current as unknown as HTMLElement | null;
@@ -143,9 +70,175 @@ export default function App() {
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
}
// Auto-scroll only when user is near the bottom
function updateSeqRefs(msgs: Message[]) {
for (const m of msgs) {
const s = Number(m.seq);
if (s > maxSeqRef.current) maxSeqRef.current = s;
}
}
const loadChannels = useCallback(async () => {
const chs = await getChannels();
setChannels(chs);
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
}, []);
// Initial load: fetch latest PAGE_SIZE messages
const loadInitialMessages = useCallback(async () => {
const channelId = activeChannelRef.current;
if (!channelId) return;
setLoading(true);
try {
const msgs = await getMessages(channelId, { limit: PAGE_SIZE });
if (activeChannelRef.current === channelId) {
setMessages(msgs);
setHasMoreBefore(msgs.length >= PAGE_SIZE);
updateSeqRefs(msgs);
initialLoadRef.current = true;
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
// Load older messages (scroll-up pagination)
const loadOlderMessages = useCallback(async () => {
const channelId = activeChannelRef.current;
if (!channelId || loadingOlder || !hasMoreBefore) return;
setLoadingOlder(true);
// Save scroll height before prepend
const vp = getViewport();
if (vp) prependScrollHeightRef.current = vp.scrollHeight;
try {
// Get the lowest seq from current messages
let lowestSeq = Infinity;
setMessages((prev) => {
for (const m of prev) {
const s = Number(m.seq);
if (s < lowestSeq) lowestSeq = s;
}
return prev;
});
if (lowestSeq === Infinity) { setLoadingOlder(false); return; }
const msgs = await getMessages(channelId, { before_seq: lowestSeq, limit: PAGE_SIZE });
if (activeChannelRef.current === channelId && msgs.length > 0) {
setHasMoreBefore(msgs.length >= PAGE_SIZE);
pendingPrependRef.current = true;
setMessages((prev) => {
const existing = new Set(prev.map((m) => m.id));
const fresh = msgs.filter((m) => !existing.has(m.id));
return fresh.length ? [...fresh, ...prev] : prev;
});
} else {
setHasMoreBefore(false);
}
} catch {
// ignore
} finally {
setLoadingOlder(false);
}
}, [loadingOlder, hasMoreBefore]);
// Gap repair: loop after_seq fetches until caught up
const repairGap = useCallback(async () => {
const channelId = activeChannelRef.current;
if (!channelId || !maxSeqRef.current) return;
let cursor = maxSeqRef.current;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
const msgs = await getMessages(channelId, { after_seq: cursor, limit: PAGE_SIZE });
if (activeChannelRef.current !== channelId || msgs.length === 0) break;
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;
});
updateSeqRefs(msgs);
cursor = maxSeqRef.current;
if (msgs.length < PAGE_SIZE) break; // caught up
} catch {
break;
}
}
}, []);
// WebSocket handlers
const handleWsMessage = useCallback((msg: Message) => {
setMessages((prev) => {
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;
}, []);
const handleWsEdit = useCallback((msg: Message) => {
setMessages((prev) => prev.map((m) => (m.id === msg.id ? msg : m)));
}, []);
const handleWsDelete = useCallback((id: string) => {
setMessages((prev) =>
prev.map((m) =>
m.id === id
? { ...m, content: "[deleted]", deleted_at: new Date().toISOString(), mentions: [] }
: m,
),
);
}, []);
// On reconnect/lag, repair the gap (don't refetch everything)
const handleWsReconnect = useCallback(() => {
repairGap();
}, [repairGap]);
useChannelSocket(activeChannelId, handleWsMessage, handleWsEdit, handleWsDelete, handleWsReconnect);
useEffect(() => { loadChannels(); }, [loadChannels]);
// Channel switch: reset and load latest
useEffect(() => {
if (messages.length > prevMsgCountRef.current) {
setMessages([]);
setSelectedMessages([]);
prevMsgCountRef.current = 0;
maxSeqRef.current = 0;
setHasMoreBefore(true);
initialLoadRef.current = true;
loadInitialMessages();
}, [activeChannelId, loadInitialMessages]);
// Scroll to bottom on initial load
useEffect(() => {
if (initialLoadRef.current && messages.length > 0) {
initialLoadRef.current = false;
// Use requestAnimationFrame to ensure DOM has rendered
requestAnimationFrame(() => scrollToBottom());
}
}, [messages]);
// Maintain scroll position after prepending older messages
useLayoutEffect(() => {
if (pendingPrependRef.current) {
pendingPrependRef.current = false;
const vp = getViewport();
if (vp) {
const newHeight = vp.scrollHeight;
const delta = newHeight - prependScrollHeightRef.current;
vp.scrollTop += delta;
}
}
}, [messages]);
// Auto-scroll on new WS messages only when near bottom
useEffect(() => {
if (!initialLoadRef.current && messages.length > prevMsgCountRef.current && !pendingPrependRef.current) {
const vp = getViewport();
const nearBottom = !vp || (vp.scrollHeight - vp.scrollTop - vp.clientHeight < 150);
if (nearBottom) scrollToBottom();
@@ -153,13 +246,17 @@ export default function App() {
prevMsgCountRef.current = messages.length;
}, [messages]);
// Track scroll position for scroll-down button
// Track scroll position for scroll-down button + trigger upward pagination
useEffect(() => {
const vp = getViewport();
if (!vp) return;
function onScroll() {
const v = vp!;
setShowScrollDown(v.scrollHeight - v.scrollTop - v.clientHeight > 150);
// Trigger load-older when near top
if (v.scrollTop < 200 && !loadingOlder && hasMoreBefore) {
loadOlderMessages();
}
}
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
@@ -243,7 +340,18 @@ export default function App() {
no messages yet start typing below
</div>
) : (
messages.map((msg, i) => {
<>
{loadingOlder && (
<div className="flex items-center justify-center py-4">
<span className="text-[10px] font-mono text-muted-foreground animate-pulse">loading older...</span>
</div>
)}
{!hasMoreBefore && messages.length > 0 && (
<div className="flex items-center justify-center py-4">
<span className="text-[10px] font-mono text-muted-foreground/40">beginning of conversation</span>
</div>
)}
{messages.map((msg, i) => {
const prev = i > 0 ? messages[i - 1] : null;
const next = i < messages.length - 1 ? messages[i + 1] : null;
const sameSender = prev && prev.user.username === msg.user.username;
@@ -305,7 +413,8 @@ export default function App() {
/>
</div>
);
})
})}
</>
)}
</ScrollArea>

View File

@@ -50,10 +50,12 @@ export async function createChannel(body: CreateChannel): Promise<Channel> {
export async function getMessages(
channelId: string,
params?: { after_seq?: number; type?: string; user_id?: string },
params?: { after_seq?: number; before_seq?: number; limit?: number; type?: string; user_id?: string },
): Promise<Message[]> {
const query = new URLSearchParams();
if (params?.after_seq) query.set("after_seq", String(params.after_seq));
if (params?.before_seq) query.set("before_seq", String(params.before_seq));
if (params?.limit) query.set("limit", String(params.limit));
if (params?.type) query.set("type", params.type);
if (params?.user_id) query.set("user_id", params.user_id);
const qs = query.toString();

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

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