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:
2026-09-15 07:16:57 +08:00
parent efc4b7f0b3
commit b59c0608d4
64 changed files with 6606 additions and 42 deletions
+66
View File
@@ -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'),
});
+2 -1
View File
@@ -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. */