- New Ask entity (ASK-####): schema + counter + paths(EntityType 'asks') +
events(AgentHubEventType 'ask') + fsWatch(WATCHED + toEvent). Generic entities
table, no migration.
- askService: createAsk routes to config.roles.architect.preferredAgent, NEVER
the CEO (a to='ceo' is rerouted); answerAsk / escalateAsk (escalatedTo='ceo',
single channel) / getAsk / listAsks. Authority-policy JSDoc.
- Asks kept OUT of FTS5: Index.upsert gains a { fts?: boolean } option; askService
upserts with fts:false, so 'memory search' never returns asks.
- routes: POST/GET /asks, GET /asks/:id, POST /asks/:id/{answer,escalate}, each
emitChange type:'ask'.
- CLI 'ask <q> --from [--task][--wait][--timeout]' (SSE reconnect wait until
status!=pending) + ask list/answer/escalate; remoteClient ask methods.
- MCP agenthub_ask (wait via waitForTask, now woken by 'ask' events) +
agenthub_ask_list/answer/escalate; agenthub_work architect branch surfaces
pending asks ({reviews,asks,messages}).
- Unattended mode (invocation flag): work.ts ctx + CLI 'work --unattended' +
agenthub_work schema + LOOP reminder ('call agenthub_ask instead of pausing').
- tests: +askService.test.ts (routing/answer/escalate/list/FTS-exclusion),
+ask-wait.test.ts (routes roundtrip + SSE wait: answer resolves, no-answer
times out cleanly)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
183 lines
7.1 KiB
TypeScript
183 lines
7.1 KiB
TypeScript
import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from '../core/schema.js';
|
|
import type { IndexEntry } from '../core/index.js';
|
|
import type { InboxMessage } from '../core/services/messageService.js';
|
|
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
|
|
|
export class RemoteError extends Error {
|
|
constructor(public status: number, message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
async function request<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<T> {
|
|
const url = `${baseUrl}${path}`;
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, {
|
|
method,
|
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new RemoteError(0, `Cannot reach AgentHub server at ${baseUrl}: ${message}`);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
throw new RemoteError(res.status, text || `HTTP ${res.status}`);
|
|
}
|
|
if (!text) {
|
|
throw new RemoteError(res.status, 'Empty response from server');
|
|
}
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
export const remoteClient = {
|
|
async getStatus(baseUrl: string): Promise<string> {
|
|
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
|
|
},
|
|
|
|
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
|
|
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
|
|
},
|
|
|
|
async updateStatus(baseUrl: string): Promise<string> {
|
|
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
|
|
},
|
|
|
|
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
|
|
return request<Task>(baseUrl, 'POST', '/tasks', options);
|
|
},
|
|
|
|
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
|
|
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
|
|
const qs = params.toString();
|
|
return request<IndexEntry[]>(baseUrl, 'GET', `/tasks${qs ? '?' + qs : ''}`);
|
|
},
|
|
|
|
async getTask(baseUrl: string, id: string): Promise<{ task: Task; body: string }> {
|
|
return request<{ task: Task; body: string }>(baseUrl, 'GET', `/tasks/${id}`);
|
|
},
|
|
|
|
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
|
|
},
|
|
|
|
async doneTask(
|
|
baseUrl: string,
|
|
id: string,
|
|
meta?: { tokens?: number; duration?: number; by?: string },
|
|
): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, {
|
|
status: 'done',
|
|
doneTokens: meta?.tokens,
|
|
doneDuration: meta?.duration,
|
|
doneBy: meta?.by,
|
|
});
|
|
},
|
|
|
|
async assignTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { assignedTo: agentName });
|
|
},
|
|
|
|
async reviewTask(baseUrl: string, id: string): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'review' });
|
|
},
|
|
|
|
async reopenTask(baseUrl: string, id: string): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'open' });
|
|
},
|
|
|
|
async getTaskActivity(baseUrl: string, id: string): Promise<ActivityItem[]> {
|
|
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
|
|
},
|
|
|
|
async appendTaskLog(
|
|
baseUrl: string,
|
|
id: string,
|
|
entry: { text: string; agent?: string; level?: string },
|
|
): Promise<TaskLogEntry> {
|
|
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
|
|
},
|
|
|
|
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
|
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
|
},
|
|
|
|
async listHandoffs(baseUrl: string): Promise<IndexEntry[]> {
|
|
return request<IndexEntry[]>(baseUrl, 'GET', '/handoffs');
|
|
},
|
|
|
|
async getHandoff(baseUrl: string, id: string): Promise<{ handoff: Handoff; body: string }> {
|
|
return request<{ handoff: Handoff; body: string }>(baseUrl, 'GET', `/handoffs/${id}`);
|
|
},
|
|
|
|
async createDecision(baseUrl: string, options: Partial<Decision>): Promise<Decision> {
|
|
return request<Decision>(baseUrl, 'POST', '/decisions', options);
|
|
},
|
|
|
|
async listDecisions(baseUrl: string): Promise<IndexEntry[]> {
|
|
return request<IndexEntry[]>(baseUrl, 'GET', '/decisions');
|
|
},
|
|
|
|
async addMemory(baseUrl: string, options: Partial<Memory>): Promise<Memory> {
|
|
return request<Memory>(baseUrl, 'POST', '/memory', options);
|
|
},
|
|
|
|
async searchMemory(baseUrl: string, query: string): Promise<IndexEntry[]> {
|
|
return request<IndexEntry[]>(baseUrl, 'GET', `/memory/search?q=${encodeURIComponent(query)}`);
|
|
},
|
|
|
|
async listMemory(baseUrl: string): Promise<IndexEntry[]> {
|
|
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 ackMessage(baseUrl: string, id: string, by?: string): Promise<Message> {
|
|
return request<Message>(baseUrl, 'POST', `/messages/${id}/ack`, by ? { by } : undefined);
|
|
},
|
|
|
|
async getMessage(baseUrl: string, id: string): Promise<{ message: Message; body: string }> {
|
|
return request<{ message: Message; body: string }>(baseUrl, 'GET', `/messages/${id}`);
|
|
},
|
|
|
|
async createAsk(baseUrl: string, options: Partial<Ask>): Promise<Ask> {
|
|
return request<Ask>(baseUrl, 'POST', '/asks', options);
|
|
},
|
|
|
|
async listAsks(baseUrl: string, filters?: { to?: string; status?: string }): Promise<Ask[]> {
|
|
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
|
|
const qs = params.toString();
|
|
return request<Ask[]>(baseUrl, 'GET', `/asks${qs ? '?' + qs : ''}`);
|
|
},
|
|
|
|
async getAsk(baseUrl: string, id: string): Promise<{ ask: Ask; body: string }> {
|
|
return request<{ ask: Ask; body: string }>(baseUrl, 'GET', `/asks/${id}`);
|
|
},
|
|
|
|
async answerAsk(baseUrl: string, id: string, text: string, by?: string): Promise<Ask> {
|
|
return request<Ask>(baseUrl, 'POST', `/asks/${id}/answer`, { text, by });
|
|
},
|
|
|
|
async escalateAsk(baseUrl: string, id: string, note?: string): Promise<Ask> {
|
|
return request<Ask>(baseUrl, 'POST', `/asks/${id}/escalate`, note ? { note } : undefined);
|
|
},
|
|
|
|
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}`);
|
|
},
|
|
};
|