From 07afa717ac7ed1a9064f9ceb0cda029ddc884003 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 29 Jun 2026 03:20:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(messaging):=20agent=20messaging=20?= =?UTF-8?q?=E2=80=94=20agenthub=5Fmessage=20/=20agenthub=5Finbox=20(cross-?= =?UTF-8?q?agent=20via=20MCP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --from ` + `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 --- package.json | 2 +- src/cli/commands/message.ts | 21 +++++ src/cli/commands/watch.ts | 2 + src/cli/index.ts | 42 +++++++++- src/cli/remoteClient.ts | 16 +++- src/core/counter.ts | 1 + src/core/paths.ts | 2 +- src/core/schema.ts | 17 ++++ src/core/services/messageService.ts | 117 ++++++++++++++++++++++++++++ src/mcp/server.ts | 57 ++++++++++---- src/server/events.ts | 2 +- src/server/fsWatch.ts | 3 + src/server/routes.ts | 41 +++++++++- src/server/statusRefresh.ts | 3 +- 14 files changed, 303 insertions(+), 23 deletions(-) create mode 100644 src/cli/commands/message.ts create mode 100644 src/core/services/messageService.ts diff --git a/package.json b/package.json index 0b17614..c2396ac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/commands/message.ts b/src/cli/commands/message.ts new file mode 100644 index 0000000..a06627f --- /dev/null +++ b/src/cli/commands/message.ts @@ -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}`); + } +} diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index 9d8b164..a5eceae 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -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. diff --git a/src/cli/index.ts b/src/cli/index.ts index 5f50ecc..50f1a81 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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): Promise', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program @@ -453,6 +454,45 @@ export function createProgram(cwd: string): Command { }); program.addCommand(decisionCmd); + // ─── messaging ─────────────────────────────────────────────────────────── + program + .command('message ') + .description('Send a direct message to another agent') + .requiredOption('--from ', 'Sender agent name') + .option('--task ', '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 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') diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index a5f41f3..2bf6ca2 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -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(baseUrl, 'GET', '/memory'); }, + async sendMessage(baseUrl: string, options: Partial): Promise { + return request(baseUrl, 'POST', '/messages', options); + }, + + async getInbox(baseUrl: string, agent: string, unreadOnly = false): Promise { + const q = `?agent=${encodeURIComponent(agent)}${unreadOnly ? '&unread=1' : ''}`; + return request(baseUrl, 'GET', `/messages${q}`); + }, + + async markMessageRead(baseUrl: string, id: string): Promise { + return request(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}`); }, diff --git a/src/core/counter.ts b/src/core/counter.ts index f14bdd1..94c2fed 100644 --- a/src/core/counter.ts +++ b/src/core/counter.ts @@ -6,6 +6,7 @@ const prefixes: Record = { handoff: 'HOF', decision: 'DEC', memory: 'MEM', + message: 'MSG', }; export type CounterType = keyof typeof prefixes; diff --git a/src/core/paths.ts b/src/core/paths.ts index 58c24a3..d2fa48d 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -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'); diff --git a/src/core/schema.ts b/src/core/schema.ts index 1442a21..47c6595 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -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; export type Handoff = z.infer; export type Decision = z.infer; export type Memory = z.infer; +export type Message = z.infer; export type Status = z.infer; export type Config = z.infer; diff --git a/src/core/services/messageService.ts b/src/core/services/messageService.ts new file mode 100644 index 0000000..c8aff85 --- /dev/null +++ b/src/core/services/messageService.ts @@ -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 { + 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; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cf391dc..f36aea6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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(serverUrl: string, findClaim: () => Promise, 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(serverUrl: string, findClaim: () => Promise, t export async function startMcpServer(cwd: string): Promise { 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 { { 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 { {}, 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. diff --git a/src/server/events.ts b/src/server/events.ts index a0002ad..ca720d8 100644 --- a/src/server/events.ts +++ b/src/server/events.ts @@ -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 { diff --git a/src/server/fsWatch.ts b/src/server/fsWatch.ts index 8352d32..9a9963d 100644 --- a/src/server/fsWatch.ts +++ b/src/server/fsWatch.ts @@ -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) } }; } } diff --git a/src/server/routes.ts b/src/server/routes.ts index 7bbd40a..bf05508 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -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); + } 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) => { diff --git a/src/server/statusRefresh.ts b/src/server/statusRefresh.ts index 69f6fba..27b77cc 100644 --- a/src/server/statusRefresh.ts +++ b/src/server/statusRefresh.ts @@ -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;