Compare commits
2 Commits
ac618d2ce3
...
5ba82869d3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ba82869d3 | |||
| 8c9745d276 |
@@ -97,7 +97,11 @@ impl MessageRow {
|
|||||||
} else {
|
} else {
|
||||||
self.content.clone()
|
self.content.clone()
|
||||||
},
|
},
|
||||||
mentions: parse_mentions(&self.content),
|
mentions: if self.deleted_at.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
parse_mentions(&self.content)
|
||||||
|
},
|
||||||
metadata: self
|
metadata: self
|
||||||
.metadata
|
.metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ mod state;
|
|||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::str::FromStr;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -14,19 +15,20 @@ async fn main() {
|
|||||||
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:colony.db?mode=rwc".into());
|
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:colony.db?mode=rwc".into());
|
||||||
let port = env::var("PORT").unwrap_or_else(|_| "3001".into());
|
let port = env::var("PORT").unwrap_or_else(|_| "3001".into());
|
||||||
|
|
||||||
|
let opts = SqliteConnectOptions::from_str(&db_url)
|
||||||
|
.expect("Invalid DATABASE_URL")
|
||||||
|
.create_if_missing(true)
|
||||||
|
.pragma("journal_mode", "WAL")
|
||||||
|
.pragma("foreign_keys", "ON");
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.connect(&db_url)
|
.connect_with(opts)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to connect to database");
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
eprintln!("colony: connected to {}", db_url);
|
eprintln!("colony: connected to {}", db_url);
|
||||||
|
|
||||||
sqlx::query("PRAGMA journal_mode=WAL")
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
sqlx::migrate!("./migrations")
|
sqlx::migrate!("./migrations")
|
||||||
.run(&pool)
|
.run(&pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -228,6 +228,11 @@ pub async fn post_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content length limit (64KB)
|
||||||
|
if body.content.len() > 65_536 {
|
||||||
|
return Err(AppError::BadRequest("Message content exceeds 64KB limit".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let user_id = resolve_user(&state.db, &user_param).await?;
|
let user_id = resolve_user(&state.db, &user_param).await?;
|
||||||
|
|
||||||
@@ -347,9 +352,9 @@ pub async fn restore_message(
|
|||||||
|
|
||||||
let message = row.to_api_message();
|
let message = row.to_api_message();
|
||||||
|
|
||||||
// Broadcast as new message (restored)
|
// Broadcast as edit (not Message — dedup would ignore same ID)
|
||||||
let tx = state.get_sender(&channel_id).await;
|
let tx = state.get_sender(&channel_id).await;
|
||||||
let _ = tx.send(WsEvent::Message(message.clone()));
|
let _ = tx.send(WsEvent::Edit(message.clone()));
|
||||||
|
|
||||||
Ok(Json(message))
|
Ok(Json(message))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ async fn handle_socket(socket: WebSocket, channel_id: String, state: AppState) {
|
|||||||
}
|
}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
eprintln!("colony: ws client lagged by {} messages", n);
|
eprintln!("colony: ws client lagged by {} messages", n);
|
||||||
|
// Tell client to refetch — they missed messages
|
||||||
|
let lag_msg = format!(r#"{{"event":"lag","missed":{}}}"#, n);
|
||||||
|
if sender.send(axum::extract::ws::Message::Text(lag_msg.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break, // Channel closed
|
Err(_) => break, // Channel closed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ password = "Apes2026!" # basic auth (fallback)
|
|||||||
[agent]
|
[agent]
|
||||||
watch_channels = ["general", "research"]
|
watch_channels = ["general", "research"]
|
||||||
max_messages_per_cycle = 5
|
max_messages_per_cycle = 5
|
||||||
heartbeat_path = "/home/agent/heartbeat.md"
|
# Paths are relative to agent home dir (e.g. /home/agents/scout/)
|
||||||
memory_path = "/home/agent/memory/memory.md"
|
heartbeat_path = "heartbeat.md"
|
||||||
|
memory_path = "memory/memory.md"
|
||||||
|
|
||||||
# Dream behavior
|
# Dream behavior
|
||||||
[dream]
|
[dream]
|
||||||
dreams_dir = "/home/agent/memory/dreams"
|
dreams_dir = "memory/dreams"
|
||||||
max_memory_lines = 500
|
max_memory_lines = 500
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -254,6 +255,13 @@ Runs on a systemd timer (every 4h). Consolidates memory and considers identity e
|
|||||||
4. Exit 0
|
4. Exit 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Worker/dream coordination:** Dream pauses the worker before running:
|
||||||
|
1. `systemctl stop agent-{name}-worker`
|
||||||
|
2. Run dream cycle (edits memory.md, CLAUDE.md)
|
||||||
|
3. `systemctl start agent-{name}-worker`
|
||||||
|
|
||||||
|
This prevents race conditions on shared files.
|
||||||
|
|
||||||
### `colony-agent birth <name> --instruction "purpose description"`
|
### `colony-agent birth <name> --instruction "purpose description"`
|
||||||
|
|
||||||
Creates a new agent on the same VM (no new VM needed).
|
Creates a new agent on the same VM (no new VM needed).
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function App() {
|
|||||||
const [showScrollDown, setShowScrollDown] = useState(false);
|
const [showScrollDown, setShowScrollDown] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevMsgCountRef = useRef(0);
|
const prevMsgCountRef = useRef(0);
|
||||||
|
const maxSeqRef = useRef(0);
|
||||||
const activeChannelRef = useRef(activeChannelId);
|
const activeChannelRef = useRef(activeChannelId);
|
||||||
|
|
||||||
activeChannelRef.current = activeChannelId;
|
activeChannelRef.current = activeChannelId;
|
||||||
@@ -58,15 +59,30 @@ export default function App() {
|
|||||||
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
|
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMessages = useCallback(async () => {
|
const loadMessages = useCallback(async (afterSeq?: number) => {
|
||||||
const channelId = activeChannelRef.current;
|
const channelId = activeChannelRef.current;
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
setLoading(true);
|
if (!afterSeq) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msgs = await getMessages(channelId);
|
const params = afterSeq ? { after_seq: afterSeq } : undefined;
|
||||||
|
const msgs = await getMessages(channelId, params);
|
||||||
if (activeChannelRef.current === channelId) {
|
if (activeChannelRef.current === channelId) {
|
||||||
|
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);
|
setMessages(msgs);
|
||||||
}
|
}
|
||||||
|
// Track highest seq for gap repair
|
||||||
|
for (const m of msgs) {
|
||||||
|
const s = Number(m.seq);
|
||||||
|
if (s > maxSeqRef.current) maxSeqRef.current = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore fetch errors
|
// Silently ignore fetch errors
|
||||||
} finally {
|
} finally {
|
||||||
@@ -80,14 +96,32 @@ export default function App() {
|
|||||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
return [...prev, msg];
|
return [...prev, msg];
|
||||||
});
|
});
|
||||||
|
const s = Number(msg.seq);
|
||||||
|
if (s > maxSeqRef.current) maxSeqRef.current = s;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// On WS reconnect, refetch full history to catch missed messages
|
// WebSocket: replace edited/restored messages
|
||||||
|
const handleWsEdit = useCallback((msg: Message) => {
|
||||||
|
setMessages((prev) => prev.map((m) => (m.id === msg.id ? msg : m)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// WebSocket: mark deleted messages
|
||||||
|
const handleWsDelete = useCallback((id: string) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === id
|
||||||
|
? { ...m, content: "[deleted]", deleted_at: new Date().toISOString(), mentions: [] }
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On WS reconnect/lag, fetch only missed messages
|
||||||
const handleWsReconnect = useCallback(() => {
|
const handleWsReconnect = useCallback(() => {
|
||||||
loadMessages();
|
loadMessages(maxSeqRef.current || undefined);
|
||||||
}, [loadMessages]);
|
}, [loadMessages]);
|
||||||
|
|
||||||
useChannelSocket(activeChannelId, handleWsMessage, handleWsReconnect);
|
useChannelSocket(activeChannelId, handleWsMessage, handleWsEdit, handleWsDelete, handleWsReconnect);
|
||||||
|
|
||||||
useEffect(() => { loadChannels(); }, [loadChannels]);
|
useEffect(() => { loadChannels(); }, [loadChannels]);
|
||||||
|
|
||||||
@@ -95,6 +129,7 @@ export default function App() {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSelectedMessages([]);
|
setSelectedMessages([]);
|
||||||
prevMsgCountRef.current = 0;
|
prevMsgCountRef.current = 0;
|
||||||
|
maxSeqRef.current = 0;
|
||||||
loadMessages();
|
loadMessages();
|
||||||
}, [activeChannelId, loadMessages]);
|
}, [activeChannelId, loadMessages]);
|
||||||
|
|
||||||
@@ -108,10 +143,12 @@ export default function App() {
|
|||||||
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
|
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll only on new messages
|
// Auto-scroll only when user is near the bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > prevMsgCountRef.current) {
|
if (messages.length > prevMsgCountRef.current) {
|
||||||
scrollToBottom();
|
const vp = getViewport();
|
||||||
|
const nearBottom = !vp || (vp.scrollHeight - vp.scrollTop - vp.clientHeight < 150);
|
||||||
|
if (nearBottom) scrollToBottom();
|
||||||
}
|
}
|
||||||
prevMsgCountRef.current = messages.length;
|
prevMsgCountRef.current = messages.length;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
@@ -254,7 +291,6 @@ export default function App() {
|
|||||||
onDelete={async (chId, msgId) => {
|
onDelete={async (chId, msgId) => {
|
||||||
try {
|
try {
|
||||||
await deleteMessage(chId, msgId);
|
await deleteMessage(chId, msgId);
|
||||||
loadMessages();
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -262,7 +298,6 @@ export default function App() {
|
|||||||
onRestore={async (chId, msgId) => {
|
onRestore={async (chId, msgId) => {
|
||||||
try {
|
try {
|
||||||
await restoreMessage(chId, msgId);
|
await restoreMessage(chId, msgId);
|
||||||
loadMessages();
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -296,7 +331,6 @@ export default function App() {
|
|||||||
onClearReply={() => setSelectedMessages([])}
|
onClearReply={() => setSelectedMessages([])}
|
||||||
onMessageSent={() => {
|
onMessageSent={() => {
|
||||||
setSelectedMessages([]);
|
setSelectedMessages([]);
|
||||||
loadMessages();
|
|
||||||
setTimeout(() => scrollToBottom(), 100);
|
setTimeout(() => scrollToBottom(), 100);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn("px-4 md:px-5", compact ? "py-0.5" : "py-3 md:py-4")}>
|
<div className={cn("px-4 md:px-5", compact ? (lastInGroup ? "pt-0.5 pb-3 md:pb-4" : "py-0.5") : "py-3 md:py-4")}>
|
||||||
{/* Header — hidden in compact mode */}
|
{/* Header — hidden in compact mode */}
|
||||||
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
{!compact && <div className="flex items-center gap-2.5 text-[11px] flex-wrap">
|
||||||
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
{/* Avatar — ape emoji with OKLCH color, agents get first letter */}
|
||||||
@@ -209,7 +209,7 @@ export function MessageItem({ message, compact, lastInGroup, replyTarget, onSele
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mt-1 text-[13px] leading-relaxed break-words font-mono",
|
"mt-1 text-sm leading-relaxed break-words font-mono",
|
||||||
message.type === "code" && "bg-muted px-3 py-2 border-2 border-border whitespace-pre-wrap overflow-x-auto",
|
message.type === "code" && "bg-muted px-3 py-2 border-2 border-border whitespace-pre-wrap overflow-x-auto",
|
||||||
message.type === "error" && "text-[var(--color-msg-error)]",
|
message.type === "error" && "text-[var(--color-msg-error)]",
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import type { Message } from "@/types/Message";
|
import type { Message } from "@/types/Message";
|
||||||
import { getCurrentUsername, getMessages } from "@/api";
|
import { getCurrentUsername } from "@/api";
|
||||||
|
|
||||||
interface WsMessageEvent {
|
interface WsMessageEvent {
|
||||||
event: "message";
|
event: "message";
|
||||||
data: Message;
|
data: Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WsEditEvent {
|
||||||
|
event: "edit";
|
||||||
|
data: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WsDeleteEvent {
|
||||||
|
event: "delete";
|
||||||
|
data: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
interface WsConnectedEvent {
|
interface WsConnectedEvent {
|
||||||
event: "connected";
|
event: "connected";
|
||||||
}
|
}
|
||||||
|
|
||||||
type WsEvent = WsMessageEvent | WsConnectedEvent;
|
interface WsLagEvent {
|
||||||
|
event: "lag";
|
||||||
|
missed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsEvent = WsMessageEvent | WsEditEvent | WsDeleteEvent | WsConnectedEvent | WsLagEvent;
|
||||||
|
|
||||||
export function useChannelSocket(
|
export function useChannelSocket(
|
||||||
channelId: string | null,
|
channelId: string | null,
|
||||||
onMessage: (msg: Message) => void,
|
onMessage: (msg: Message) => void,
|
||||||
|
onEdit: (msg: Message) => void,
|
||||||
|
onDelete: (id: string) => void,
|
||||||
onReconnect: () => void,
|
onReconnect: () => void,
|
||||||
) {
|
) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const intentionalClose = useRef(false);
|
const intentionalClose = useRef(false);
|
||||||
|
const backoffMs = useRef(3000);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
@@ -32,6 +50,7 @@ export function useChannelSocket(
|
|||||||
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
|
const ws = new WebSocket(`${protocol}//${host}/ws/${channelId}`);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
backoffMs.current = 3000; // Reset backoff on successful connect
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: "auth",
|
type: "auth",
|
||||||
user: getCurrentUsername(),
|
user: getCurrentUsername(),
|
||||||
@@ -43,8 +62,12 @@ export function useChannelSocket(
|
|||||||
const event: WsEvent = JSON.parse(e.data);
|
const event: WsEvent = JSON.parse(e.data);
|
||||||
if (event.event === "message") {
|
if (event.event === "message") {
|
||||||
onMessage(event.data);
|
onMessage(event.data);
|
||||||
} else if (event.event === "connected") {
|
} else if (event.event === "edit") {
|
||||||
// Refetch history on reconnect to catch missed messages
|
onEdit(event.data);
|
||||||
|
} else if (event.event === "delete") {
|
||||||
|
onDelete(event.data.id);
|
||||||
|
} else if (event.event === "connected" || event.event === "lag") {
|
||||||
|
// Refetch to catch missed messages
|
||||||
onReconnect();
|
onReconnect();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -57,19 +80,18 @@ export function useChannelSocket(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
// Only reconnect if this wasn't an intentional teardown
|
|
||||||
if (!intentionalClose.current) {
|
if (!intentionalClose.current) {
|
||||||
reconnectTimer.current = setTimeout(connect, 3000);
|
reconnectTimer.current = setTimeout(connect, backoffMs.current);
|
||||||
|
backoffMs.current = Math.min(backoffMs.current * 2, 30000); // Exponential backoff, max 30s
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
}, [channelId, onMessage, onReconnect]);
|
}, [channelId, onMessage, onEdit, onDelete, onReconnect]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
return () => {
|
return () => {
|
||||||
// Mark as intentional so onclose doesn't reconnect
|
|
||||||
intentionalClose.current = true;
|
intentionalClose.current = true;
|
||||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||||
wsRef.current?.close();
|
wsRef.current?.close();
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground font-mono;
|
@apply bg-background text-foreground font-mono antialiased;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user