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>
This commit is contained in:
2026-03-29 22:32:32 +02:00
parent 8c9745d276
commit 5ba82869d3
9 changed files with 115 additions and 35 deletions

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

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

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