mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-23 22:09:56 +00:00
feat(hub,web,cli): add Agent Team (sessions-as-teammates)
Hub
- independent teams.db (own user_version ladder); feature gate default OFF,
so a disabled hub never creates the file and team routes simply do not exist
- /api/teams: list/detail/by-session/memory/messages/spawn/tasks,
human message channel, team rename/archive/delete
- team runtime: spawn members via the existing runner RPC (worktree/yolo
options), peer message routing (mention/dm push, broadcast pull-only),
budgets (max members, message rate, reply chain depth), member-offline
announcements that wake the lead or escalate to the human
- team-attention notifications: in-app toast when visible, web push otherwise
- team memory dir {HAPI_HOME}/teams/<id>/ with charter.md template; read-only
browse API with path-traversal guards
CLI
- MCP tools: team_status / team_read / team_send / spawn_peer (claude, codex
bridge and ACP flavors); spawn_peer stays behind manual approval
- HAPI_TEAM_* env for spawned members; system-prompt block for Claude/Codex;
assignment brief carries team rules for flavors without prompt injection
- RUNNER_CAPABILITIES.agentTeam advertised to the hub
Web
- team group chat route with folded peer threads, key/task filters,
human composer, member/decision/system message cards
- side panel: live member status, interactive task board (create/status/
assignee), team memory browser, budget
- team create / add-member / settings dialogs; sidebar team section;
hub settings toggle for the feature (restart to apply)
- SSE team-updated invalidation
Zero impact when disabled: no teams.db, no routes, no tool or prompt changes.
This commit is contained in:
@@ -289,6 +289,72 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const forwardTeamTool = async (name: string, args: Record<string, unknown>) => {
|
||||
try {
|
||||
const client = await ensureHttpClient();
|
||||
const response = await client.callTool({ name, arguments: args });
|
||||
return response as any;
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `${name} failed: ${error instanceof Error ? error.message : String(error)}` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const TEAM_TOOL_DEFS: Array<{ name: string; title: string; description: string; inputSchema: z.ZodTypeAny }> = [
|
||||
{
|
||||
name: 'team_status',
|
||||
title: 'Team Status',
|
||||
description: 'Agent Team: show your team, members, pending tasks and remaining budget. Call this right after starting as a team member to pick up your assignment.',
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
{
|
||||
name: 'team_read',
|
||||
title: 'Read Team Messages',
|
||||
description: 'Agent Team: read the shared team message log. Broadcasts are pull-only: call this to catch up before starting work.',
|
||||
inputSchema: z.object({
|
||||
afterSeq: z.number().int().nonnegative().optional().describe('Return messages with seq greater than this'),
|
||||
limit: z.number().int().positive().max(2000).optional().describe('Max messages to return (default 100)'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'team_send',
|
||||
title: 'Send Team Message',
|
||||
description: 'Agent Team: send a message to teammates. to="all" (default) writes to the shared log only; to="<role or session id prefix>" or to="lead" wakes that member; to="human" notifies the human out-of-band (use it or kind="decision" when you need a human decision). Use inReplyTo=<seq> when answering a peer message.',
|
||||
inputSchema: z.object({
|
||||
text: z.string().min(1).describe('Message text'),
|
||||
to: z.string().min(1).optional().describe('"all" (default), "lead", "human", or a member session id/prefix'),
|
||||
kind: z.enum(['chat', 'status', 'question', 'task-update', 'decision']).optional(),
|
||||
inReplyTo: z.number().int().positive().optional().describe('seq of the message you are replying to'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'spawn_peer',
|
||||
title: 'Spawn Team Peer',
|
||||
description: 'Agent Team: spawn a new teammate session with a role and an initial task. Requires user approval.',
|
||||
inputSchema: z.object({
|
||||
role: z.string().min(1).describe('Role / display name, unique in the team'),
|
||||
task: z.string().min(1).optional().describe('Initial task brief delivered to the new member'),
|
||||
agent: z.string().min(1).optional().describe('Agent flavor (claude, codex, ...). Defaults to the caller flavor.'),
|
||||
model: z.string().min(1).optional().describe('Optional model override'),
|
||||
worktree: z.boolean().optional().describe('Run the member in an isolated git worktree'),
|
||||
worktreeName: z.string().min(1).max(80).optional(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
for (const def of TEAM_TOOL_DEFS) {
|
||||
if (!toolNames.has(def.name)) continue;
|
||||
server.registerTool<any, any>(
|
||||
def.name,
|
||||
{ description: def.description, title: def.title, inputSchema: def.inputSchema },
|
||||
async (args: Record<string, unknown>) => forwardTeamTool(def.name, args)
|
||||
);
|
||||
}
|
||||
|
||||
const skillLookupInputSchema: z.ZodTypeAny = z.object({
|
||||
name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { trimIdent } from '@/utils/trimIdent';
|
||||
import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation';
|
||||
import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt';
|
||||
import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction';
|
||||
import { withTeamInstruction } from '@/modules/team/teamPrompt';
|
||||
|
||||
/**
|
||||
* Title instruction for Codex to call the hapi MCP tool.
|
||||
@@ -36,7 +37,7 @@ export const TITLE_INSTRUCTION = trimIdent(`
|
||||
* Session-summary contract is resolved at call time (hub toggle / env).
|
||||
*/
|
||||
export function getCodexSystemPrompt(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return withSessionSummaryInstruction(TITLE_INSTRUCTION, env)
|
||||
return withTeamInstruction(withSessionSummaryInstruction(TITLE_INSTRUCTION, env), 'functions.hapi__', env)
|
||||
}
|
||||
|
||||
/** Alias kept for existing call sites / tests that expect a string constant name. */
|
||||
|
||||
Reference in New Issue
Block a user