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:
parent
4672677bf5
commit
07afa717ac
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "0.7.5",
|
||||
"version": "0.8.0",
|
||||
"description": "Local coordination layer for AI coding agents",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
21
src/cli/commands/message.ts
Normal file
21
src/cli/commands/message.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@ -79,6 +79,8 @@ function describeEvent(event: AgentHubEvent): { label: string; detail: string }
|
||||
return { label: 'Decision', detail: event.title ?? '' };
|
||||
case 'memory':
|
||||
return { label: 'Memory', detail: event.title ?? '' };
|
||||
case 'message':
|
||||
return { label: 'Message', detail: event.title ?? '' };
|
||||
default:
|
||||
// 'agent' presence events are formatted in formatEvent() before reaching
|
||||
// here; this fallback only satisfies exhaustiveness.
|
||||
|
||||
@ -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 { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||
import { messageSend, inboxList } from './commands/message.js';
|
||||
import { delegate } from './commands/delegate.js';
|
||||
import { serverStart } from './commands/server.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 {
|
||||
const program = new Command('agenthub')
|
||||
.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)');
|
||||
|
||||
program
|
||||
@ -453,6 +454,45 @@ export function createProgram(cwd: string): Command {
|
||||
});
|
||||
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
|
||||
.command('delegate')
|
||||
.description('Suggest or auto-delegate open tasks')
|
||||
|
||||
@ -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 { InboxMessage } from '../core/services/messageService.js';
|
||||
|
||||
export class RemoteError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
@ -123,6 +124,19 @@ export const remoteClient = {
|
||||
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 }> {
|
||||
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
|
||||
},
|
||||
|
||||
@ -6,6 +6,7 @@ const prefixes: Record<string, string> = {
|
||||
handoff: 'HOF',
|
||||
decision: 'DEC',
|
||||
memory: 'MEM',
|
||||
message: 'MSG',
|
||||
};
|
||||
|
||||
export type CounterType = keyof typeof prefixes;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { existsSync } from 'fs';
|
||||
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 {
|
||||
return join(cwd, '.agenthub');
|
||||
|
||||
@ -71,6 +71,22 @@ export const MemorySchema = z.object({
|
||||
by: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Direct message between agents (architect↔implementer, or agent↔agent),
|
||||
* 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
|
||||
export const ActivityItemSchema = z.object({
|
||||
at: z.string().datetime(),
|
||||
@ -124,5 +140,6 @@ export type Task = z.infer<typeof TaskSchema>;
|
||||
export type Handoff = z.infer<typeof HandoffSchema>;
|
||||
export type Decision = z.infer<typeof DecisionSchema>;
|
||||
export type Memory = z.infer<typeof MemorySchema>;
|
||||
export type Message = z.infer<typeof MessageSchema>;
|
||||
export type Status = z.infer<typeof StatusSchema>;
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
117
src/core/services/messageService.ts
Normal file
117
src/core/services/messageService.ts
Normal 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;
|
||||
}
|
||||
@ -17,6 +17,7 @@ import {
|
||||
doneTask,
|
||||
} from '../core/services/taskService.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 { createDecision } from '../core/services/decisionService.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 });
|
||||
const { events, remaining } = parseSSEBuffer(buffer);
|
||||
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();
|
||||
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> {
|
||||
const { root, serverUrl } = resolveContext(cwd);
|
||||
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.',
|
||||
{ agent: z.string(), role: z.string().optional() },
|
||||
@ -107,24 +109,37 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
||||
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() },
|
||||
async ({ agent, role, timeoutSec }) => {
|
||||
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
|
||||
const findClaim = async () => {
|
||||
const found = await findAddressedOpenTask(ctx);
|
||||
if (!found) return null;
|
||||
if (remote) await remoteClient.claimTask(serverUrl!, 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 hofEntry = found.handoffs.find((h) => h.taskId === found.task.id);
|
||||
let handoff: unknown = null;
|
||||
if (hofEntry) {
|
||||
try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
|
||||
// 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 { claimed: found.task, body: (detail as { body?: string }).body, handoff };
|
||||
return msgs;
|
||||
};
|
||||
const immediate = await findClaim();
|
||||
const findWork = async () => {
|
||||
const found = await findAddressedOpenTask(ctx);
|
||||
if (found) {
|
||||
if (remote) await remoteClient.claimTask(serverUrl!, 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 hofEntry = found.handoffs.find((h) => h.taskId === found.task.id);
|
||||
let handoff: unknown = null;
|
||||
if (hofEntry) {
|
||||
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, 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 findWork();
|
||||
if (immediate) return asText(immediate);
|
||||
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);
|
||||
return asText(claimed ?? `No task for ${agent} within ${timeoutSec ?? 300}s — call agenthub_work again.`);
|
||||
const result = await waitForTask(serverUrl!, findWork, timeoutSec ?? 300);
|
||||
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.',
|
||||
@ -187,6 +202,16 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
||||
{},
|
||||
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();
|
||||
await server.connect(transport);
|
||||
// stdio servers must not write to stdout (it's the protocol channel); log to stderr.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 interface AgentHubEvent {
|
||||
|
||||
@ -25,6 +25,7 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
|
||||
{ dir: 'handoffs', type: 'handoff' },
|
||||
{ dir: 'decisions', type: 'decision' },
|
||||
{ dir: 'memory', type: 'memory' },
|
||||
{ dir: 'messages', type: 'message' },
|
||||
];
|
||||
|
||||
// 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) } };
|
||||
case 'memory':
|
||||
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) } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancel
|
||||
import { getTaskActivity } from '../core/services/activityService.js';
|
||||
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.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 { getStatus, updateStatus } from '../core/services/statusService.js';
|
||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||
@ -11,7 +12,7 @@ import { renderBoardHtml } from './board.js';
|
||||
import { renderTeamHtml } from './team.js';
|
||||
import { eventBus, emitChange } 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) {
|
||||
return reply.status(404).send({ error: `${resource} not found` });
|
||||
@ -268,6 +269,44 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
app.get('/memory', async () => listMemory(cwd));
|
||||
app.post('/memory', async (request, reply) => {
|
||||
|
||||
@ -20,7 +20,8 @@ export function startStatusAutoRefresh(cwd: string): () => void {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
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);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user