Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ agn up # start the daemon
| **Hermes Agent** | ✅ Supported | Nous Hermes CLI with tools, profiles, and memory |
| **Cursor** | ✅ Supported | AI code editor |
| **OpenCode** | ✅ Supported | Open-source terminal agent |
| **Kimi** | ✅ Supported | Moonshot AI OpenAI-compatible agent |
| Aider, Goose, Gemini CLI, Copilot, Amp | 🔜 Coming soon | |

---
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-connector/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ async function cmdRemove(connector, _flags, positional) {
async function cmdStart(connector, _flags, positional) {
const name = positional[0];
if (!name) { print('Usage: agn start <name>'); return; }
const pid = connector.getDaemonPid();
if (!pid) {
connector.startDaemon();
print(`Daemon starting; it will launch configured agents including '${name}'`);
return;
}
connector.sendDaemonCommand(`restart:${name}`);
print(`Sent start command for '${name}'`);
}
Expand Down
31 changes: 30 additions & 1 deletion packages/agent-connector/src/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,27 @@ class Daemon {
* Read daemon PID, returning null if not running.
*/
static readDaemonPid(configDir) {
return Daemon._readPid(path.join(configDir, 'daemon.pid'));
const pidFile = path.join(configDir, 'daemon.pid');
const statusFile = path.join(configDir, 'daemon.status.json');
const pid = Daemon._readPid(pidFile);
if (!pid) return null;

// Windows can reuse old PIDs quickly; process.kill(pid, 0) may report
// an unrelated process as alive. The daemon rewrites status every 5s, so
// an old status file is the reliable signal that the PID file is stale.
const now = Date.now();
const pidAgeMs = Daemon._fileAgeMs(pidFile, now);
const statusAgeMs = Daemon._fileAgeMs(statusFile, now);
const justStarted = pidAgeMs !== null && pidAgeMs < 15000;
const hasFreshStatus = statusAgeMs !== null && statusAgeMs < 20000;

if (justStarted || hasFreshStatus) {
return pid;
}

try { fs.unlinkSync(pidFile); } catch {}
try { fs.unlinkSync(statusFile); } catch {}
return null;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -822,6 +842,15 @@ class Daemon {
return false;
}
}

static _fileAgeMs(file, now = Date.now()) {
try {
if (!fs.existsSync(file)) return null;
return now - fs.statSync(file).mtimeMs;
} catch {
return null;
}
}
}

module.exports = { Daemon };
18 changes: 16 additions & 2 deletions packages/agent-connector/test/daemon.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,26 @@ describe('Daemon', () => {
assert.equal(Daemon.readDaemonPid(tmpDir), process.pid);
});

it('readDaemonPid returns pid without validating liveness', () => {
it('readDaemonPid keeps a just-written pid while daemon status is pending', () => {
fs.writeFileSync(path.join(tmpDir, 'daemon.pid'), '99999999', 'utf-8');
// PID validation removed — returns raw value (liveness checked elsewhere)
assert.equal(Daemon.readDaemonPid(tmpDir), 99999999);
});

it('readDaemonPid clears stale pid and status files', () => {
const pidFile = path.join(tmpDir, 'daemon.pid');
const statusFile = path.join(tmpDir, 'daemon.status.json');
fs.writeFileSync(pidFile, String(process.pid), 'utf-8');
fs.writeFileSync(statusFile, '{"agents":{}}', 'utf-8');

const oldDate = new Date(Date.now() - 60000);
fs.utimesSync(pidFile, oldDate, oldDate);
fs.utimesSync(statusFile, oldDate, oldDate);

assert.equal(Daemon.readDaemonPid(tmpDir), null);
assert.equal(fs.existsSync(pidFile), false);
assert.equal(fs.existsSync(statusFile), false);
});

it('_reload is serialized (concurrent calls queue)', async () => {
const config = new Config(tmpDir);
const env = new EnvManager(tmpDir);
Expand Down
12 changes: 10 additions & 2 deletions packages/launcher/src/main/agent-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,13 @@ class AgentManager {

async startAgent(name) {
// Ensure daemon is running (long-lived background process)
await this._ensureDaemon();
const daemonResult = await this._ensureDaemon();
if (daemonResult && daemonResult.success === false) {
throw new Error(daemonResult.message || 'Failed to start daemon');
}
if (!this._connector.getDaemonPid()) {
throw new Error('Daemon is not running. Check the Launcher runtime setup and try again.');
}
// Send start command — daemon will launch the agent's adapter
this._connector.sendDaemonCommand(`start:${name}`);
return { success: true, message: `Start command sent for ${name}` };
Expand Down Expand Up @@ -389,7 +395,9 @@ class AgentManager {
// Check both unified path (symlink) and legacy platform-specific path
const nodeBin = path.join(portableNodeDir, 'node' + (process.platform === 'win32' ? '.exe' : ''));
const nodeBinLegacy = path.join(portableNodeDir, 'bin', 'node');
if (!fs.existsSync(nodeBin) && !fs.existsSync(nodeBinLegacy)) return;
if (!fs.existsSync(nodeBin) && !fs.existsSync(nodeBinLegacy)) {
return { success: false, message: 'Node.js runtime not found. Restart Launcher or reinstall the runtime from Settings.' };
}

return this._startDaemon();
}
Expand Down
5 changes: 5 additions & 0 deletions packages/launcher/src/renderer/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,17 +432,22 @@ async function toggleAgent(name, currentState) {
await window.api.startAgent(name);
showToast(`Starting ${name}...`, 'info');
// Poll until running (up to 30s — daemon needs time to connect)
let reportedRunning = false;
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 3000));
const status = await window.api.agentStatus();
const agent = status[name];
if (agent && (agent.state === 'running' || agent.state === 'online')) {
showToast(`${name} is now running`, 'success');
reportedRunning = true;
break;
}
scheduleRefreshDashboard();
scheduleRefreshAgentList();
}
if (!reportedRunning) {
showToast(`${name} did not report as running yet. Check Logs for details.`, 'warning');
}
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
Expand Down
2 changes: 1 addition & 1 deletion workspace/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class EventRecord(Base):
type = Column(Text, nullable=False) # e.g. "workspace.message.posted"
source = Column(Text, nullable=False) # e.g. "openagents:claude-agent"
target = Column(Text, nullable=False) # e.g. "channel/session-abc"
payload = Column(JSONB)
payload = Column(JSONB) # optional sender fields live here
metadata_ = Column("metadata", JSONB, default={}) # underscore to avoid Python keyword
timestamp = Column(BigInteger, nullable=False) # unix ms
visibility = Column(Text, default="channel")
Expand Down
15 changes: 14 additions & 1 deletion workspace/backend/app/routers/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class SendEventRequest(BaseModel):
metadata: Optional[dict] = None
visibility: Optional[str] = "channel"
network: Optional[str] = None # workspace ID or slug
sender_id: Optional[str] = None
sender_name: Optional[str] = None
sender_type: Optional[str] = None


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -79,12 +82,21 @@ async def send_event(
if not workspace:
return json_response(ResponseCode.NOT_FOUND, "Network not found")

payload = body.payload or {}
if body.type.startswith("workspace.message") and body.source.startswith("human:"):
if body.sender_id and "sender_id" not in payload:
payload["sender_id"] = body.sender_id
if body.sender_name and "sender_name" not in payload:
payload["sender_name"] = body.sender_name
if body.sender_type and "sender_type" not in payload:
payload["sender_type"] = body.sender_type

# Build ONM Event
event = Event(
type=body.type,
source=body.source,
target=body.target,
payload=body.payload,
payload=payload,
metadata=body.metadata or {},
visibility=body.visibility or "channel",
network=str(workspace.id),
Expand Down Expand Up @@ -140,6 +152,7 @@ async def send_event(
"type": result.type,
"source": result.source,
"target": result.target,
"payload": result.payload,
"timestamp": result.timestamp,
"metadata": result.metadata,
})
Expand Down
25 changes: 21 additions & 4 deletions workspace/frontend/components/chat/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ function isPreviewable(contentType: string, filename: string): boolean {
return false;
}

function humanColor(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return `hsl(${hash % 360} 55% 82%)`;
}

function Attachments({ items }: { items: Attachment[] }) {
if (!items || items.length === 0) return null;

Expand Down Expand Up @@ -108,7 +116,8 @@ interface ChatMessageProps {
}

export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: ChatMessageProps) {
const isHuman = message.senderType === 'human';
const { currentUser } = useWorkspace();
const isHuman = message.senderType === 'human' || message.senderType === 'user';
const isSystem = message.messageType === 'status';
const [copied, setCopied] = useState(false);

Expand Down Expand Up @@ -150,15 +159,23 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: C

// ── Human message — Slack style ──
if (isHuman) {
const isCurrentUser = !!message.senderId && message.senderId === currentUser.id;
const displayName = isCurrentUser
? 'You'
: (message.senderName && message.senderName !== 'user' ? message.senderName : 'User');
const seed = message.senderId || message.senderName || 'human';
return (
<div className="py-1.5">
<div className="flex items-start gap-2">
<div className="size-9 rounded-lg shrink-0 flex items-center justify-center bg-zinc-200 dark:bg-zinc-700 mt-0.5">
<User className="size-4 text-zinc-500 dark:text-zinc-400" />
<div
className="size-9 rounded-lg shrink-0 flex items-center justify-center mt-0.5"
style={{ backgroundColor: humanColor(seed) }}
>
<User className="size-4 text-zinc-700" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-[15px] font-bold text-foreground">You</span>
<span className="text-[15px] font-bold text-foreground">{displayName}</span>
{timestamp && (
<span className="text-xs text-muted-foreground">{timestamp}</span>
)}
Expand Down
14 changes: 9 additions & 5 deletions workspace/frontend/components/chat/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ async function refreshCachedSession(sessionId: string): Promise<void> {
}

export function ChatView() {
const { agents, currentSessionId, sessions, updateLastMessage, setSessionActive, agentModes, updateAgentMode, toggleAgentMode, stopAllAgents, activeSessionIds, stoppingSessionIds, renameSession, addParticipant, removeParticipant, consumeSkipFocus } = useWorkspace();
const { agents, currentUser, currentSessionId, sessions, updateLastMessage, setSessionActive, agentModes, updateAgentMode, toggleAgentMode, stopAllAgents, activeSessionIds, stoppingSessionIds, renameSession, addParticipant, removeParticipant, consumeSkipFocus } = useWorkspace();
const { isMobile, openMobileList, splitBrowser, showBrowserPreview, setShowBrowserPreview } = useLayout();

// Continuously refresh message caches for top recent sessions in the background.
Expand Down Expand Up @@ -307,15 +307,17 @@ export function ChatView() {
const handleSend = useCallback(
async (content: string, mentions: string[] = [], files: PendingFile[] = []) => {
if (!currentSessionId) return;
if (!currentUser.id || !currentUser.name.trim()) return;

// Create optimistic messages for instant feedback
const timestamp = Date.now();
const userContent = content || (files.length > 0 ? files.map((f) => f.file.name).join(', ') : '');
const userOptimisticMsg: WorkspaceMessage = {
messageId: `optimistic-user-${timestamp}`,
sessionId: currentSessionId,
senderName: 'You',
senderType: 'user',
senderId: currentUser.id,
senderName: currentUser.name,
senderType: 'human',
content: userContent,
messageType: 'chat',
mentions: [],
Expand Down Expand Up @@ -358,9 +360,10 @@ export function ChatView() {
await workspaceApi.sendMessage(
currentSessionId,
content || (attachments ? attachments.map((a) => a.filename).join(', ') : ''),
'user',
currentUser.name,
mentions.length > 0 ? mentions : undefined,
attachments,
currentUser.id,
);
forceRefresh();
} catch {
Expand All @@ -369,7 +372,7 @@ export function ChatView() {
setOptimisticMessages([]);
}
},
[currentSessionId, forceRefresh, agents]
[currentSessionId, currentUser.id, currentUser.name, forceRefresh, agents]
);

const hasStatusMessages = displayMessages.some((m) => m.messageType === 'status' || m.messageType === 'thinking');
Expand Down Expand Up @@ -699,6 +702,7 @@ export function ChatView() {
<ChatInput
onSend={handleSend}
agents={agents}
disabled={!currentUser.name.trim()}
draft={currentDraft}
onDraftChange={handleDraftChange}
focusKey={focusKey}
Expand Down
29 changes: 27 additions & 2 deletions workspace/frontend/components/layout/sidebar-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
import {
Plus, MessageSquare, FileText, Globe, PlusSquare,
Settings, Copy, Check, ListTodo, CalendarClock,
LogIn, LogOut, Shield, Moon, Sun, KeyRound, Share2, X, Crown,
LogIn, LogOut, Shield, Moon, Sun, KeyRound, Share2, X, Crown, Users,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { ScrollArea } from '@/components/ui/scroll-area';
Expand Down Expand Up @@ -70,7 +70,7 @@ function NavButton({

export function SidebarContent() {
const { isSidebarOpen, sidebarToggle, viewMode, setViewMode, setSelectedAgentName } = useLayout();
const { agents, sessions, files, browserTabs, createSession, workspace, token, refreshWorkspace } = useWorkspace();
const { agents, currentUser, onlineUsers, sessions, files, browserTabs, createSession, workspace, token, refreshWorkspace } = useWorkspace();
const { user, isOpenAgentsDomain, signIn, signOut } = useOpenAgentsAuth();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
Expand Down Expand Up @@ -261,6 +261,31 @@ export function SidebarContent() {
})}
</div>

{/* Online users */}
<p className="text-xs font-normal text-muted-foreground px-2 py-1.5 mb-0.5 mt-6 flex items-center gap-1.5">
<Users className="size-3" />
Online users ({onlineUsers.length})
</p>
<div className="space-y-0.5">
{onlineUsers.map((user) => (
<div
key={user.id}
className="w-full flex items-center gap-2 px-2 h-8 rounded-lg"
>
<div className="size-5 rounded-full bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-[9px] font-bold text-zinc-700 dark:text-zinc-200 shrink-0 relative">
{(user.name || 'U').slice(0, 1).toUpperCase()}
<span className="absolute -end-0.5 -bottom-0.5 size-2 rounded-full border-[1.5px] border-background bg-green-500" />
</div>
<span className="text-[13px] font-normal text-foreground truncate text-left">
{user.id === currentUser.id ? `${user.name} (you)` : user.name}
</span>
</div>
))}
{onlineUsers.length === 0 && (
<p className="text-[12px] text-muted-foreground px-2 py-1">No users online</p>
)}
</div>

{/* Collaboration */}
<p className="text-xs font-normal text-muted-foreground px-2 py-1.5 mb-0.5 mt-6">
Collaboration
Expand Down
Loading
Loading