Files
hapi/cli/src/codex/happyMcpStdioBridge.ts
T
wushenghua b59c0608d4 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.
2026-09-15 07:16:57 +08:00

398 lines
15 KiB
TypeScript

/**
* HAPI MCP STDIO Bridge
*
* Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `list_peers`, `ping_peer`, and `inspect_peer`.
* On invocation it forwards the tool call to an existing HAPI HTTP MCP server
* using the StreamableHTTPClientTransport.
*
* Configure the target HTTP MCP URL via env var `HAPI_HTTP_MCP_URL` or
* via CLI flag `--url <http://127.0.0.1:PORT>`.
*
* Note: This process must not print to stdout as it would break MCP STDIO.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { z } from 'zod';
import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from '@/modules/common/displayImagePrompt';
import {
INSPECT_PEER_TOOL_DESCRIPTION,
PING_PEER_TOOL_DESCRIPTION,
SESSION_ID_PREFIX_PARAM_DESCRIPTION,
} from '@hapi/protocol/sessionCitation';
const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'];
function parseArgs(argv: string[]): { url: string | null; toolNames: Set<string> } {
let url: string | null = null;
let toolNames = new Set(DEFAULT_TOOL_NAMES);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--url' && i + 1 < argv.length) {
url = argv[i + 1];
i++;
} else if (a === '--tools' && i + 1 < argv.length) {
toolNames = new Set(argv[i + 1].split(',').map((name) => name.trim()).filter(Boolean));
i++;
}
}
return { url, toolNames };
}
export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
try {
// Resolve target HTTP MCP URL
const { url: urlFromArgs, toolNames } = parseArgs(argv);
const baseUrl = urlFromArgs || process.env.HAPI_HTTP_MCP_URL || '';
if (!baseUrl) {
// Write to stderr; never stdout.
process.stderr.write(
'[hapi-mcp] Missing target URL. Set HAPI_HTTP_MCP_URL or pass --url <http://127.0.0.1:PORT>\n'
);
process.exit(2);
}
let httpClient: Client | null = null;
async function ensureHttpClient(): Promise<Client> {
if (httpClient) return httpClient;
const client = new Client(
{ name: 'hapi-stdio-bridge', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(baseUrl));
await client.connect(transport);
httpClient = client;
return client;
}
// Create STDIO MCP server
const server = new McpServer({
name: 'HAPI MCP Bridge',
version: '1.0.0',
});
// Register tools and forward to HTTP MCP
const changeTitleInputSchema: z.ZodTypeAny = z.object({
title: z.string().describe('The new title for the chat session'),
});
if (toolNames.has('change_title')) {
server.registerTool<any, any>(
'change_title',
{
description: 'Change the title of the current chat session',
title: 'Change Chat Title',
inputSchema: changeTitleInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'change_title', arguments: args });
// Pass-through response from HTTP server
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
const displayImageInputSchema: z.ZodTypeAny = z.object({
path: z.string().describe('Absolute filesystem path of the local image to display to the human user. This file is sent for user display, not provided to the model for image inspection'),
title: z.string().optional().describe('Optional display title or filename shown to the human user'),
});
if (toolNames.has('display_image')) {
server.registerTool<any, any>(
'display_image',
{
description: `Display a local image file to the human user inline in the current HAPI chat session. ${DISPLAY_IMAGE_PROMPT_CURSOR}`,
title: 'Display Image',
inputSchema: displayImageInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'display_image', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to display image: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
const displayVideoInputSchema: z.ZodTypeAny = z.object({
path: z.string().describe('Local filesystem path of the video to display inline (mp4 or webm)'),
title: z.string().optional().describe('Optional display title or filename for the video'),
});
if (toolNames.has('display_video')) {
server.registerTool<any, any>(
'display_video',
{
description: `Display a local mp4 or webm file inline in the current HAPI chat session. ${DISPLAY_VIDEO_PROMPT_CURSOR}`,
title: 'Display Video',
inputSchema: displayVideoInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'display_video', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to display video: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
const displayMediaInputSchema: z.ZodTypeAny = z.object({
path: z.string().describe('Local filesystem path of the media or file to send to the user'),
title: z.string().trim().min(1).max(255).optional().describe('Optional display title or filename'),
});
if (toolNames.has('display_media')) {
server.registerTool<any, any>(
'display_media',
{
description: `Send a local image, video, audio, or other file to the current HAPI chat session. ${DISPLAY_MEDIA_PROMPT_CURSOR}`,
title: 'Display Media',
inputSchema: displayMediaInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
return await client.callTool({ name: 'display_media', arguments: args }) as any;
} catch (error) {
return {
content: [{ type: 'text' as const, text: `Failed to display media: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
}
);
}
const pingPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION),
message: z.string().min(1).describe('Message text to deliver to the target session'),
});
if (toolNames.has('ping_peer')) {
server.registerTool<any, any>(
'ping_peer',
{
description: PING_PEER_TOOL_DESCRIPTION,
title: 'Ping Peer Session',
inputSchema: pingPeerInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'ping_peer', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to ping peer: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
const inspectPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION),
messageLimit: z.number().int().min(1).max(100).optional().describe(
'Recent message page size (default 30, max 100). Text snippets only.'
),
});
if (toolNames.has('inspect_peer')) {
server.registerTool<any, any>(
'inspect_peer',
{
description: INSPECT_PEER_TOOL_DESCRIPTION,
title: 'Inspect Peer Session',
inputSchema: inspectPeerInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'inspect_peer', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to inspect peer: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
const listPeersInputSchema: z.ZodTypeAny = z.object({
limit: z.number().int().min(1).max(100).optional().describe(
'Max sessions to return (default 30, max 100). Newest updatedAt first.'
),
});
if (toolNames.has('list_peers')) {
server.registerTool<any, any>(
'list_peers',
{
description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.',
title: 'List Peer Sessions',
inputSchema: listPeersInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'list_peers', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to list peers: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
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'),
});
if (toolNames.has('skill_lookup')) {
server.registerTool<any, any>(
'skill_lookup',
{
description: 'Load a HAPI skill by exact name. When a user message starts with $name, call this tool with that name before acting.',
title: 'Look Up Skill',
inputSchema: skillLookupInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'skill_lookup', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to look up skill: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
// Start STDIO transport
const stdio = new StdioServerTransport();
await server.connect(stdio);
} catch (err) {
try {
process.stderr.write(`[hapi-mcp] Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
} finally {
process.exit(1);
}
}
}