feat(messaging): agent messaging — agenthub_message / agenthub_inbox (cross-agent via MCP)

Direct messages between agents through the hub — architect↔agent and agent↔agent
questions, clarifications and pings that handoffs/memories don't cover. Built as
MCP tools so kimi + codex get it too (not Claude-only), plus REST + CLI.

- Message entity (MSG-NNNN): schema, counter, messages/ dir, messageService
  (createMessage / listInbox / listMessages / markMessageRead).
- REST: POST /messages, GET /messages[?agent&unread], POST /messages/:id/read,
  with SSE emit. fsWatch + statusRefresh wired ('message' events; status skips them).
- MCP: agenthub_message {from,to,text,taskId?} + agenthub_inbox {agent,unreadOnly?}.
  agenthub_work now surfaces + drains unread messages and wakes on message events,
  so an agent sees messages inside its work loop. (18 tools total.)
- CLI: `agenthub message <to> <text> --from <agent>` + `agenthub inbox --agent`.
  watch stream shows "Message" events (architect-visible).
- Architect-visible by design: the hub stays the coordination point.

Bump 0.7.5 -> 0.8.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-29 03:20:38 +02:00
parent 4672677bf5
commit 07afa717ac
14 changed files with 303 additions and 23 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "0.7.5", "version": "0.8.0",
"description": "Local coordination layer for AI coding agents", "description": "Local coordination layer for AI coding agents",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@ -0,0 +1,21 @@
import { createMessage, listInbox } from '../../core/services/messageService.js';
export function messageSend(
cwd: string,
opts: { from: string; to: string; text: string; taskId?: string },
): void {
const m = createMessage(cwd, opts);
console.log(`AgentHub: Message sent ${m.id} (${m.from}${m.to})`);
}
export function inboxList(cwd: string, opts: { agent: string; unreadOnly?: boolean }): void {
const msgs = listInbox(cwd, opts.agent, { unreadOnly: opts.unreadOnly });
if (msgs.length === 0) {
console.log(`No messages for ${opts.agent}.`);
return;
}
for (const m of msgs) {
const flag = m.status === 'unread' ? '●' : ' ';
console.log(`${flag} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
}
}

View File

@ -79,6 +79,8 @@ function describeEvent(event: AgentHubEvent): { label: string; detail: string }
return { label: 'Decision', detail: event.title ?? '' }; return { label: 'Decision', detail: event.title ?? '' };
case 'memory': case 'memory':
return { label: 'Memory', detail: event.title ?? '' }; return { label: 'Memory', detail: event.title ?? '' };
case 'message':
return { label: 'Message', detail: event.title ?? '' };
default: default:
// 'agent' presence events are formatted in formatEvent() before reaching // 'agent' presence events are formatted in formatEvent() before reaching
// here; this fallback only satisfies exhaustiveness. // here; this fallback only satisfies exhaustiveness.

View File

@ -5,6 +5,7 @@ import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './co
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js'; import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js'; import { decisionCreate, decisionList } from './commands/decision.js';
import { messageSend, inboxList } from './commands/message.js';
import { delegate } from './commands/delegate.js'; import { delegate } from './commands/delegate.js';
import { serverStart } from './commands/server.js'; import { serverStart } from './commands/server.js';
import { update } from './commands/update.js'; import { update } from './commands/update.js';
@ -116,7 +117,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
export function createProgram(cwd: string): Command { export function createProgram(cwd: string): Command {
const program = new Command('agenthub') const program = new Command('agenthub')
.description('Local coordination layer for AI coding agents') .description('Local coordination layer for AI coding agents')
.version('0.7.5') .version('0.8.0')
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)'); .option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
program program
@ -453,6 +454,45 @@ export function createProgram(cwd: string): Command {
}); });
program.addCommand(decisionCmd); program.addCommand(decisionCmd);
// ─── messaging ───────────────────────────────────────────────────────────
program
.command('message <to> <text>')
.description('Send a direct message to another agent')
.requiredOption('--from <agent>', 'Sender agent name')
.option('--task <id>', 'Related task ID')
.action(async (to: string, text: string, options: { from: string; task?: string }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
const payload = { from: options.from, to, text, taskId: options.task };
if (serverUrl) {
await runRemote(serverUrl, async () => {
const m = await remoteClient.sendMessage(serverUrl, payload);
console.log(`AgentHub: Message sent ${m.id} (${m.from}${m.to})`);
});
} else {
messageSend(projectCwd, payload);
}
});
program
.command('inbox')
.description('Read messages addressed to an agent')
.requiredOption('--agent <agent>', 'Agent whose inbox to read')
.option('--unread', 'Only unread messages')
.action(async (options: { agent: string; unread?: boolean }) => {
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
if (serverUrl) {
await runRemote(serverUrl, async () => {
const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread);
if (msgs.length === 0) { console.log(`No messages for ${options.agent}.`); return; }
for (const m of msgs) {
console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from}${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
}
});
} else {
inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
}
});
program program
.command('delegate') .command('delegate')
.description('Suggest or auto-delegate open tasks') .description('Suggest or auto-delegate open tasks')

View File

@ -1,5 +1,6 @@
import type { Task, Handoff, Decision, Memory, ActivityItem } from '../core/schema.js'; import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js';
import type { IndexEntry } from '../core/index.js'; import type { IndexEntry } from '../core/index.js';
import type { InboxMessage } from '../core/services/messageService.js';
export class RemoteError extends Error { export class RemoteError extends Error {
constructor(public status: number, message: string) { constructor(public status: number, message: string) {
@ -123,6 +124,19 @@ export const remoteClient = {
return request<IndexEntry[]>(baseUrl, 'GET', '/memory'); return request<IndexEntry[]>(baseUrl, 'GET', '/memory');
}, },
async sendMessage(baseUrl: string, options: Partial<Message>): Promise<Message> {
return request<Message>(baseUrl, 'POST', '/messages', options);
},
async getInbox(baseUrl: string, agent: string, unreadOnly = false): Promise<InboxMessage[]> {
const q = `?agent=${encodeURIComponent(agent)}${unreadOnly ? '&unread=1' : ''}`;
return request<InboxMessage[]>(baseUrl, 'GET', `/messages${q}`);
},
async markMessageRead(baseUrl: string, id: string): Promise<Message> {
return request<Message>(baseUrl, 'POST', `/messages/${id}/read`);
},
async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> { async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> {
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`); return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
}, },

View File

@ -6,6 +6,7 @@ const prefixes: Record<string, string> = {
handoff: 'HOF', handoff: 'HOF',
decision: 'DEC', decision: 'DEC',
memory: 'MEM', memory: 'MEM',
message: 'MSG',
}; };
export type CounterType = keyof typeof prefixes; export type CounterType = keyof typeof prefixes;

View File

@ -1,7 +1,7 @@
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { dirname, join, parse } from 'path'; import { dirname, join, parse } from 'path';
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'status'; export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'messages' | 'status';
export function getAgentHubDir(cwd: string = process.cwd()): string { export function getAgentHubDir(cwd: string = process.cwd()): string {
return join(cwd, '.agenthub'); return join(cwd, '.agenthub');

View File

@ -71,6 +71,22 @@ export const MemorySchema = z.object({
by: z.string().optional(), by: z.string().optional(),
}); });
/**
* Direct message between agents (architectimplementer, or agentagent),
* routed through the hub. Lightweight, non-task-bound for questions,
* clarifications and pings the handoff/memory entities don't cover.
*/
export const MessageSchema = z.object({
id: z.string().min(1),
from: z.string().min(1),
to: z.string().min(1),
text: z.string().min(1),
taskId: z.string().optional(),
status: z.enum(['unread', 'read']).default('unread'),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
// Activity timeline item returned by GET /tasks/:id/activity // Activity timeline item returned by GET /tasks/:id/activity
export const ActivityItemSchema = z.object({ export const ActivityItemSchema = z.object({
at: z.string().datetime(), at: z.string().datetime(),
@ -124,5 +140,6 @@ export type Task = z.infer<typeof TaskSchema>;
export type Handoff = z.infer<typeof HandoffSchema>; export type Handoff = z.infer<typeof HandoffSchema>;
export type Decision = z.infer<typeof DecisionSchema>; export type Decision = z.infer<typeof DecisionSchema>;
export type Memory = z.infer<typeof MemorySchema>; export type Memory = z.infer<typeof MemorySchema>;
export type Message = z.infer<typeof MessageSchema>;
export type Status = z.infer<typeof StatusSchema>; export type Status = z.infer<typeof StatusSchema>;
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

@ -0,0 +1,117 @@
import { join } from 'path';
import { getEntityDir } from '../paths.js';
import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { MessageSchema, type Message } from '../schema.js';
import { Index } from '../index.js';
function indexEntryFor(record: Message, filePath: string) {
return {
id: record.id,
type: 'message',
title: `${record.from}${record.to}`,
content: record.text,
filePath,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
status: record.status,
fromAgent: record.from,
toAgent: record.to,
taskId: record.taskId,
};
}
/** Send a direct message from one agent to another. */
export function createMessage(cwd: string, options: Partial<Message> = {}): Message {
if (!options.from) throw new Error('Message requires a "from" agent');
if (!options.to) throw new Error('Message requires a "to" agent');
if (!options.text) throw new Error('Message requires text');
const now = new Date().toISOString();
const record: Message = MessageSchema.parse({
id: getNextId(cwd, 'message'),
from: options.from,
to: options.to,
text: options.text,
taskId: options.taskId,
status: 'unread',
createdAt: now,
updatedAt: now,
});
const filePath = join(getEntityDir(cwd, 'messages'), `${record.id}.md`);
writeEntity(filePath, record, `# ${record.from}${record.to}\n\n${record.text}`);
const index = new Index(cwd);
index.upsert(indexEntryFor(record, filePath));
index.close();
return record;
}
export interface InboxMessage {
id: string;
from: string;
to: string;
text: string;
taskId?: string;
status: string;
createdAt: string;
}
/** Messages addressed to `agent`, newest first. */
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
const index = new Index(cwd);
const all = index.list('message');
index.close();
return all
.filter((m) => m.toAgent === agent)
.filter((m) => !opts.unreadOnly || m.status === 'unread')
.map((m) => ({
id: m.id,
from: m.fromAgent ?? '',
to: m.toAgent ?? '',
text: m.content,
taskId: m.taskId,
status: m.status ?? 'unread',
createdAt: m.createdAt,
}));
}
/** All messages (architect-visible view), newest first. */
export function listMessages(cwd: string): InboxMessage[] {
const index = new Index(cwd);
const all = index.list('message');
index.close();
return all.map((m) => ({
id: m.id,
from: m.fromAgent ?? '',
to: m.toAgent ?? '',
text: m.content,
taskId: m.taskId,
status: m.status ?? 'unread',
createdAt: m.createdAt,
}));
}
export function getMessage(cwd: string, id: string): { message: Message; body: string; filePath: string } {
if (!id) throw new Error('Message ID is required');
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
return { message: MessageSchema.parse(frontmatter), body, filePath };
}
/** Mark a message as read. */
export function markMessageRead(cwd: string, id: string): Message {
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
const { frontmatter, body } = readEntity(filePath);
const message = MessageSchema.parse(frontmatter);
const updated: Message = { ...message, status: 'read', updatedAt: new Date().toISOString() };
writeEntity(filePath, updated, body);
const index = new Index(cwd);
index.upsert(indexEntryFor(updated, filePath));
index.close();
return updated;
}

View File

@ -17,6 +17,7 @@ import {
doneTask, doneTask,
} from '../core/services/taskService.js'; } from '../core/services/taskService.js';
import { createHandoff, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, getHandoff } from '../core/services/handoffService.js';
import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
import { addMemory, searchMemory } from '../core/services/memoryService.js'; import { addMemory, searchMemory } from '../core/services/memoryService.js';
import { createDecision } from '../core/services/decisionService.js'; import { createDecision } from '../core/services/decisionService.js';
import { getStatus } from '../core/services/statusService.js'; import { getStatus } from '../core/services/statusService.js';
@ -79,7 +80,8 @@ function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, t
if (value) buffer += decoder.decode(value, { stream: true }); if (value) buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEBuffer(buffer); const { events, remaining } = parseSSEBuffer(buffer);
buffer = remaining; buffer = remaining;
if (events.some((e) => e.type === 'task')) { // Wake on a new task OR a new message addressed to the agent.
if (events.some((e) => e.type === 'task' || e.type === 'message')) {
const claimed = await findClaim(); const claimed = await findClaim();
if (claimed) { clearTimeout(timer); finish(claimed); return; } if (claimed) { clearTimeout(timer); finish(claimed); return; }
} }
@ -93,7 +95,7 @@ function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, t
export async function startMcpServer(cwd: string): Promise<void> { export async function startMcpServer(cwd: string): Promise<void> {
const { root, serverUrl } = resolveContext(cwd); const { root, serverUrl } = resolveContext(cwd);
const remote = !!serverUrl; const remote = !!serverUrl;
const server = new McpServer({ name: 'agenthub', version: '0.7.0' }); const server = new McpServer({ name: 'agenthub', version: '0.8.0' });
server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.', server.tool('agenthub_hello', 'Announce yourself to the hub (presence). Run once when you start.',
{ agent: z.string(), role: z.string().optional() }, { agent: z.string(), role: z.string().optional() },
@ -107,9 +109,18 @@ export async function startMcpServer(cwd: string): Promise<void> {
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() }, { agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() },
async ({ agent, role, timeoutSec }) => { async ({ agent, role, timeoutSec }) => {
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' }; const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
const findClaim = async () => { // Fetch + mark-read the agent's unread messages, so the work loop surfaces
// them once and doesn't spin on the same message.
const drainInbox = async () => {
const msgs = remote ? await remoteClient.getInbox(serverUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
for (const m of msgs) {
try { if (remote) await remoteClient.markMessageRead(serverUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
}
return msgs;
};
const findWork = async () => {
const found = await findAddressedOpenTask(ctx); const found = await findAddressedOpenTask(ctx);
if (!found) return null; if (found) {
if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent); if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent);
else claimTask(root, found.task.id, agent); else claimTask(root, found.task.id, agent);
const detail = remote ? await remoteClient.getTask(serverUrl!, found.task.id) : getTask(root, found.task.id); const detail = remote ? await remoteClient.getTask(serverUrl!, found.task.id) : getTask(root, found.task.id);
@ -118,13 +129,17 @@ export async function startMcpServer(cwd: string): Promise<void> {
if (hofEntry) { if (hofEntry) {
try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ } try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
} }
return { claimed: found.task, body: (detail as { body?: string }).body, handoff }; return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox() };
}
const messages = await drainInbox();
if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' };
return null;
}; };
const immediate = await findClaim(); const immediate = await findWork();
if (immediate) return asText(immediate); if (immediate) return asText(immediate);
if (!remote) return asText(`No open task addressed to ${agent}, and no hub server to wait on.`); if (!remote) return asText(`No open task addressed to ${agent}, and no hub server to wait on.`);
const claimed = await waitForTask(serverUrl!, findClaim, timeoutSec ?? 300); const result = await waitForTask(serverUrl!, findWork, timeoutSec ?? 300);
return asText(claimed ?? `No task for ${agent} within ${timeoutSec ?? 300}s — call agenthub_work again.`); return asText(result ?? `No task or message for ${agent} within ${timeoutSec ?? 300}s — call agenthub_work again.`);
}); });
server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.', server.tool('agenthub_task_list', 'List tasks, optionally filtered by status and/or role.',
@ -187,6 +202,16 @@ export async function startMcpServer(cwd: string): Promise<void> {
{}, {},
async () => asText(remote ? await remoteClient.getStatus(serverUrl!) : getStatus(root))); async () => asText(remote ? await remoteClient.getStatus(serverUrl!) : getStatus(root)));
server.tool('agenthub_message',
'Send a direct message to another agent (architect↔agent or agent↔agent) — for questions, clarifications and pings. Routed through the hub; the architect sees all messages.',
{ from: z.string(), to: z.string(), text: z.string(), taskId: z.string().optional() },
async (o) => asText(remote ? await remoteClient.sendMessage(serverUrl!, o) : createMessage(root, o)));
server.tool('agenthub_inbox',
'Read messages addressed to you. Pass unreadOnly:true for just the new ones. agenthub_work also surfaces unread messages automatically.',
{ agent: z.string(), unreadOnly: z.boolean().optional() },
async ({ agent, unreadOnly }) => asText(remote ? await remoteClient.getInbox(serverUrl!, agent, unreadOnly) : listInbox(root, agent, { unreadOnly })));
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
// stdio servers must not write to stdout (it's the protocol channel); log to stderr. // stdio servers must not write to stdout (it's the protocol channel); log to stderr.

View File

@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'agent'; export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'agent';
export type AgentHubEventAction = 'created' | 'updated' | 'joined' | 'left'; export type AgentHubEventAction = 'created' | 'updated' | 'joined' | 'left';
export interface AgentHubEvent { export interface AgentHubEvent {

View File

@ -25,6 +25,7 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
{ dir: 'handoffs', type: 'handoff' }, { dir: 'handoffs', type: 'handoff' },
{ dir: 'decisions', type: 'decision' }, { dir: 'decisions', type: 'decision' },
{ dir: 'memory', type: 'memory' }, { dir: 'memory', type: 'memory' },
{ dir: 'messages', type: 'message' },
]; ];
// fs.watch can fire several events (rename + change) for a single write, and a // fs.watch can fire several events (rename + change) for a single write, and a
@ -70,6 +71,8 @@ function toEvent(
return { stamp, event: { type, action, id, title: str(fm.title) } }; return { stamp, event: { type, action, id, title: str(fm.title) } };
case 'memory': case 'memory':
return { stamp, event: { type, action, id, title: str(fm.title) } }; return { stamp, event: { type, action, id, title: str(fm.title) } };
case 'message':
return { stamp, event: { type, action, id, title: `${str(fm.from)}${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } };
} }
} }

View File

@ -3,6 +3,7 @@ import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancel
import { getTaskActivity } from '../core/services/activityService.js'; import { getTaskActivity } from '../core/services/activityService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { createMessage, listMessages, listInbox, markMessageRead } from '../core/services/messageService.js';
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
import { getStatus, updateStatus } from '../core/services/statusService.js'; import { getStatus, updateStatus } from '../core/services/statusService.js';
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
@ -11,7 +12,7 @@ import { renderBoardHtml } from './board.js';
import { renderTeamHtml } from './team.js'; import { renderTeamHtml } from './team.js';
import { eventBus, emitChange } from './events.js'; import { eventBus, emitChange } from './events.js';
import type { AgentHubEvent } from './events.js'; import type { AgentHubEvent } from './events.js';
import type { Task, Handoff, Decision, Memory } from '../core/schema.js'; import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js';
function notFound(reply: FastifyReply, resource: string) { function notFound(reply: FastifyReply, resource: string) {
return reply.status(404).send({ error: `${resource} not found` }); return reply.status(404).send({ error: `${resource} not found` });
@ -268,6 +269,44 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
return decision; return decision;
}); });
// ─── Messages ────────────────────────────────────────────────────────────
// Direct agent-to-agent / architect-to-agent messages. GET /messages returns
// all (architect view); GET /messages/inbox?agent=X&unread=1 returns one
// agent's inbox.
app.get('/messages', async (request) => {
const { agent, unread } = request.query as { agent?: string; unread?: string };
if (agent) return listInbox(cwd, agent, { unreadOnly: unread === '1' || unread === 'true' });
return listMessages(cwd);
});
app.post('/messages', async (request, reply) => {
let message: Message;
try {
message = createMessage(cwd, request.body as Partial<Message>);
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid message');
}
emitChange(
{
type: 'message',
action: 'created',
id: message.id,
title: `${message.from}${message.to}`,
},
message.updatedAt,
);
return message;
});
app.post('/messages/:id/read', async (request, reply) => {
const { id } = request.params as { id: string };
try {
const message = markMessageRead(cwd, id);
emitChange({ type: 'message', action: 'updated', id: message.id, status: 'read' }, message.updatedAt);
return message;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Message not found');
}
});
// ─── Memory ────────────────────────────────────────────────────────────── // ─── Memory ──────────────────────────────────────────────────────────────
app.get('/memory', async () => listMemory(cwd)); app.get('/memory', async () => listMemory(cwd));
app.post('/memory', async (request, reply) => { app.post('/memory', async (request, reply) => {

View File

@ -20,7 +20,8 @@ export function startStatusAutoRefresh(cwd: string): () => void {
let timer: NodeJS.Timeout | undefined; let timer: NodeJS.Timeout | undefined;
const onChange = (event: AgentHubEvent) => { const onChange = (event: AgentHubEvent) => {
if (event.type === 'agent') return; // Presence + messages don't change task status — skip the snapshot rewrite.
if (event.type === 'agent' || event.type === 'message') return;
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
timer = setTimeout(() => { timer = setTimeout(() => {
timer = undefined; timer = undefined;