From b59c0608d4252da8ea2286caa3e1e5887e9c90a6 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 15 Sep 2026 07:16:57 +0800 Subject: [PATCH] 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// 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. --- cli/src/api/apiMachine.ts | 11 +- cli/src/claude/utils/startHappyServer.ts | 142 ++- cli/src/claude/utils/systemPrompt.ts | 3 +- cli/src/codex/happyMcpStdioBridge.ts | 66 ++ cli/src/codex/utils/systemPrompt.ts | 3 +- cli/src/modules/common/rpcTypes.ts | 5 + cli/src/modules/pingPeer/pingPeer.ts | 6 +- cli/src/modules/team/teamClient.test.ts | 118 +++ cli/src/modules/team/teamClient.ts | 303 ++++++ cli/src/modules/team/teamPrompt.test.ts | 29 + cli/src/modules/team/teamPrompt.ts | 30 + cli/src/runner/run.ts | 9 + hub/src/config/serverSettings.test.ts | 40 + hub/src/config/serverSettings.ts | 18 + hub/src/config/settings.ts | 6 + hub/src/config/teamsSetting.ts | 34 + hub/src/configuration.ts | 18 +- hub/src/notifications/notificationHub.test.ts | 43 +- hub/src/notifications/notificationHub.ts | 36 +- hub/src/notifications/notificationTypes.ts | 16 + hub/src/push/pushNotificationChannel.test.ts | 74 ++ hub/src/push/pushNotificationChannel.ts | 50 +- hub/src/startHub.ts | 84 ++ hub/src/sync/machineCache.test.ts | 28 + hub/src/sync/messageService.ts | 2 +- hub/src/sync/rpcGateway.ts | 8 +- hub/src/sync/syncEngine.ts | 36 +- hub/src/teams/teamIsolation.test.ts | 67 ++ hub/src/teams/teamService.test.ts | 622 +++++++++++ hub/src/teams/teamService.ts | 977 ++++++++++++++++++ hub/src/teams/teamStore.test.ts | 199 ++++ hub/src/teams/teamStore.ts | 670 ++++++++++++ hub/src/web/routes/hubSettings.test.ts | 45 +- hub/src/web/routes/hubSettings.ts | 21 +- hub/src/web/routes/teams.test.ts | 297 ++++++ hub/src/web/routes/teams.ts | 285 +++++ hub/src/web/server.ts | 11 +- shared/src/apiTypes.ts | 83 +- shared/src/runnerCapabilities.ts | 7 +- shared/src/schemas.ts | 19 +- web/src/api/client.ts | 108 ++ web/src/components/SessionList.tsx | 3 + .../components/teams/TeamAddMemberDialog.tsx | 139 +++ web/src/components/teams/TeamChatPage.tsx | 383 +++++++ web/src/components/teams/TeamCreateDialog.tsx | 115 +++ web/src/components/teams/TeamDialog.tsx | 40 + web/src/components/teams/TeamMemoryDialog.tsx | 69 ++ .../components/teams/TeamSettingsDialog.tsx | 158 +++ web/src/components/teams/TeamSidePanel.tsx | 228 ++++ .../components/teams/TeamSidebarSection.tsx | 63 ++ web/src/components/teams/TeamTaskDialog.tsx | 123 +++ web/src/components/teams/TeamTimeline.tsx | 209 ++++ web/src/components/teams/teamStatus.ts | 17 + web/src/hooks/queries/useTeam.ts | 36 + web/src/hooks/queries/useTeamMemory.ts | 28 + web/src/hooks/queries/useTeamMessages.ts | 36 + web/src/hooks/queries/useTeams.ts | 34 + web/src/hooks/useSSE.ts | 31 +- web/src/lib/locales/en.ts | 93 ++ web/src/lib/locales/zh-CN.ts | 93 ++ web/src/lib/query-keys.ts | 5 + web/src/router.tsx | 35 + web/src/routes/settings/general.tsx | 15 +- web/src/types/team.ts | 66 ++ 64 files changed, 6606 insertions(+), 42 deletions(-) create mode 100644 cli/src/modules/team/teamClient.test.ts create mode 100644 cli/src/modules/team/teamClient.ts create mode 100644 cli/src/modules/team/teamPrompt.test.ts create mode 100644 cli/src/modules/team/teamPrompt.ts create mode 100644 hub/src/config/teamsSetting.ts create mode 100644 hub/src/teams/teamIsolation.test.ts create mode 100644 hub/src/teams/teamService.test.ts create mode 100644 hub/src/teams/teamService.ts create mode 100644 hub/src/teams/teamStore.test.ts create mode 100644 hub/src/teams/teamStore.ts create mode 100644 hub/src/web/routes/teams.test.ts create mode 100644 hub/src/web/routes/teams.ts create mode 100644 web/src/components/teams/TeamAddMemberDialog.tsx create mode 100644 web/src/components/teams/TeamChatPage.tsx create mode 100644 web/src/components/teams/TeamCreateDialog.tsx create mode 100644 web/src/components/teams/TeamDialog.tsx create mode 100644 web/src/components/teams/TeamMemoryDialog.tsx create mode 100644 web/src/components/teams/TeamSettingsDialog.tsx create mode 100644 web/src/components/teams/TeamSidePanel.tsx create mode 100644 web/src/components/teams/TeamSidebarSection.tsx create mode 100644 web/src/components/teams/TeamTaskDialog.tsx create mode 100644 web/src/components/teams/TeamTimeline.tsx create mode 100644 web/src/components/teams/teamStatus.ts create mode 100644 web/src/hooks/queries/useTeam.ts create mode 100644 web/src/hooks/queries/useTeamMemory.ts create mode 100644 web/src/hooks/queries/useTeamMessages.ts create mode 100644 web/src/hooks/queries/useTeams.ts create mode 100644 web/src/types/team.ts diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 1ce90d19..81dedfca 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -510,7 +510,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => { - const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, collaborationMode, copilotAgentMode, token, sessionType, worktreeName, startingMode, forkSession } = params || {} + const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, collaborationMode, copilotAgentMode, token, sessionType, worktreeName, startingMode, forkSession, team } = params || {} if (!directory) { throw new Error('Directory is required') @@ -541,7 +541,14 @@ export class ApiMachineClient { sessionType, worktreeName, startingMode, - forkSession: forkSession === true + forkSession: forkSession === true, + team: team && typeof team === 'object' + ? { + id: String(team.id ?? ''), + name: String(team.name ?? ''), + role: String(team.role ?? '') + } + : undefined }) switch (result.type) { diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index f14af6c0..d320945c 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -27,6 +27,16 @@ import { SESSION_ID_PREFIX_PARAM_DESCRIPTION, } from '@hapi/protocol/sessionCitation' import { PingPeerError, formatInspectPeerReport, formatPeerSessionsList, inspectPeer, listPeerSessions, peerListFetchLimit, pingPeer } from "@/modules/pingPeer/pingPeer"; +import { + TeamClientError, + formatTeamMessages, + formatTeamStatus, + readTeamMessages, + probeTeamsSupport, + resolveCurrentTeam, + sendTeamMessage, + spawnTeamMember +} from "@/modules/team/teamClient"; type StartHappyServerOptions = { emitTitleSummary?: boolean; @@ -42,7 +52,8 @@ const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set([ 'display_media', 'display_video', 'ping_peer', - 'inspect_peer' + 'inspect_peer', + 'spawn_peer' ]); /** @@ -61,7 +72,8 @@ function createHapiMcpServer( client: ApiSessionClient, emitTitleSummary: boolean, enableChangeTitle: boolean, - skillLookup: StartHappyServerOptions['skillLookup'] + skillLookup: StartHappyServerOptions['skillLookup'], + teamToolsEnabled: boolean ): McpServer { const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); @@ -413,6 +425,123 @@ function createHapiMcpServer( } }); + if (teamToolsEnabled) { + const teamErrorText = (error: unknown): string => { + if (error instanceof TeamClientError) return error.message; + return error instanceof Error ? error.message : String(error); + }; + // Membership is resolved per call so sessions that join a team after + // startup (e.g. adopted as lead) get working tools without a restart. + const requireTeam = async () => { + const status = await resolveCurrentTeam({ sessionId: client.sessionId }); + if (!status) { + throw new TeamClientError('not_in_team', 'This session is not a member of any team yet. Ask the human to add it to a team first.'); + } + return status; + }; + + mcp.registerTool('team_status', { + description: 'Agent Team: show your team, members (live status), pending tasks and remaining budget. Call this right after starting as a team member to pick up your assignment.', + title: 'Team Status', + inputSchema: z.object({}), + }, async () => { + try { + const status = await requireTeam(); + return { content: [{ type: 'text' as const, text: formatTeamStatus(status) }], isError: false }; + } catch (error) { + const message = teamErrorText(error); + return { content: [{ type: 'text' as const, text: `Failed to load team status: ${message}` }], isError: error instanceof TeamClientError && error.code === 'not_in_team' ? false : true }; + } + }); + + mcp.registerTool('team_read', { + description: 'Agent Team: read the shared team message log (all members see the same log). Use afterSeq from a previous read to fetch only new messages. Broadcasts are pull-only: call this before starting work to catch up.', + title: 'Read Team Messages', + 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)'), + }), + }, async (args: { afterSeq?: number; limit?: number }) => { + try { + const status = await requireTeam(); + const messages = await readTeamMessages({ + sessionId: client.sessionId, + teamId: status.team.id, + afterSeq: args.afterSeq, + limit: args.limit ?? 100, + }); + return { content: [{ type: 'text' as const, text: formatTeamMessages(messages, client.sessionId) }], isError: false }; + } catch (error) { + return { content: [{ type: 'text' as const, text: `Failed to read team messages: ${teamErrorText(error)}` }], isError: true }; + } + }); + + mcp.registerTool('team_send', { + description: 'Agent Team: send a message to teammates. to="all" (default) writes to the shared log only; to="" 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= when answering a peer message so the hub can stop runaway back-and-forth. Do NOT use this for routine replies to the human.', + title: 'Send Team 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().describe('Message kind (default chat)'), + inReplyTo: z.number().int().positive().optional().describe('seq of the message you are replying to'), + }), + }, async (args: { text: string; to?: string; kind?: string; inReplyTo?: number }) => { + try { + const status = await requireTeam(); + const message = await sendTeamMessage({ + sessionId: client.sessionId, + teamId: status.team.id, + text: args.text, + to: args.to, + kind: args.kind, + inReplyTo: args.inReplyTo, + }); + return { + content: [{ type: 'text' as const, text: `Sent as team message #${message.seq}${args.to && args.to !== 'all' ? ` to ${args.to}` : ' (broadcast)'}` }], + isError: false, + }; + } catch (error) { + return { content: [{ type: 'text' as const, text: `Failed to send team message: ${teamErrorText(error)}` }], isError: true }; + } + }); + + mcp.registerTool('spawn_peer', { + description: 'Agent Team: spawn a new teammate session (own context window) with a role and an initial task. The member joins this team and reports back via team messages. Spawning costs tokens - prefer reusing idle members; requires user approval.', + title: 'Spawn Team Peer', + inputSchema: z.object({ + role: z.string().min(1).describe('Role / display name, e.g. "Builder A" or "Reviewer". Must be 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 (default: follow the caller)'), + worktreeName: z.string().min(1).max(80).optional().describe('Explicit worktree name'), + }), + }, async (args: { role: string; task?: string; agent?: string; model?: string; worktree?: boolean; worktreeName?: string }) => { + try { + const status = await requireTeam(); + const result = await spawnTeamMember({ + sessionId: client.sessionId, + teamId: status.team.id, + role: args.role, + task: args.task, + agent: args.agent, + model: args.model, + sessionType: args.worktree === undefined ? undefined : (args.worktree ? 'worktree' : 'simple'), + worktreeName: args.worktreeName, + }); + return { + content: [{ + type: 'text' as const, + text: `Spawned ${result.role} (session ${result.sessionId.slice(0, 8)})${result.taskId ? ` with task ${result.taskId}` : ''}. The task is delivered when the member is ready.`, + }], + isError: false, + }; + } catch (error) { + return { content: [{ type: 'text' as const, text: `Failed to spawn peer: ${teamErrorText(error)}` }], isError: true }; + } + }); + } + if (skillLookup) { mcp.registerTool('skill_lookup', { @@ -475,11 +604,15 @@ function readMcpSessionId(req: IncomingMessage): string | undefined { export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { const emitTitleSummary = options.emitTitleSummary ?? true; const enableChangeTitle = options.enableChangeTitle ?? true; + // Team tools are registered only when the hub reports the feature enabled + // (probe -> 404 when disabled, so nothing changes for non-team hubs). + // Membership itself is resolved per tool call, so mid-life team joins work. + const teamToolsEnabled = await probeTeamsSupport(); const transports = new Map(); const mcps = new Map(); const createMcpTransport = () => { - const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup); + const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup, teamToolsEnabled); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { @@ -536,6 +669,9 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH const toolNames = enableChangeTitle ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'] : ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; + if (teamToolsEnabled) { + toolNames.push('team_status', 'team_read', 'team_send', 'spawn_peer'); + } if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 3174ac6e..a90ec5c1 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -3,6 +3,7 @@ import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCita import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; +import { withTeamInstruction } from "@/modules/team/teamPrompt"; /** * Base system prompt shared across all configurations @@ -42,5 +43,5 @@ export function getSystemPrompt(): string { const base = includeCoAuthored ? BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS : BASE_SYSTEM_PROMPT; - return withSessionSummaryInstruction(base); + return withTeamInstruction(withSessionSummaryInstruction(base), 'mcp__hapi__'); } diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index be0d03d0..1dff45bf 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -289,6 +289,72 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { ); } + const forwardTeamTool = async (name: string, args: Record) => { + 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="" 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= 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( + def.name, + { description: def.description, title: def.title, inputSchema: def.inputSchema }, + async (args: Record) => 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'), }); diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index bd85efc3..9fd36e07 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -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. */ diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 53ae84f1..451c0543 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -29,6 +29,11 @@ export interface SpawnSessionOptions { forkSession?: boolean /** Runner-internal post-create containment revalidation. Never serialized. */ validateDirectory?: (path: string) => Promise + /** + * Agent Team context (hub-set). The runner exports HAPI_TEAM_* env vars so + * the child CLI can register team MCP tools and identify its role. + */ + team?: { id: string; name: string; role: string } } export type SpawnSessionResult = diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index 24a22c8b..75ada864 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -90,7 +90,7 @@ const AUTH_RECOVERY_HINT = 'or run `hapi auth login` to save the token. Inside a HAPI session prefer MCP ' + '`list_peers` / `ping_peer` / `inspect_peer`, which use the session CLI credentials.' -function resolveApiUrl(apiUrl?: string): string { +export function resolveApiUrl(apiUrl?: string): string { const raw = (apiUrl ?? configuration.apiUrl).trim().replace(/\/+$/, '') if (!raw) { throw new PingPeerError( @@ -103,7 +103,7 @@ function resolveApiUrl(apiUrl?: string): string { return raw } -function resolveAccessToken(accessToken?: string): string { +export function resolveAccessToken(accessToken?: string): string { let token = '' try { token = (accessToken ?? getAuthToken()).trim() @@ -123,7 +123,7 @@ function authFailedMessage(apiUrl: string, detail: string): string { return `failed to exchange access token for JWT (${detail}). Hub URL: ${apiUrl}. ${AUTH_RECOVERY_HINT}` } -async function exchangeJwt( +export async function exchangeJwt( apiUrl: string, accessToken: string, http: AxiosInstance diff --git a/cli/src/modules/team/teamClient.test.ts b/cli/src/modules/team/teamClient.test.ts new file mode 100644 index 00000000..1b89ad24 --- /dev/null +++ b/cli/src/modules/team/teamClient.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AxiosInstance } from 'axios' + +import { + fetchTeamStatus, + formatTeamMessages, + formatTeamStatus, + sendTeamMessage, + type TeamStatusView +} from './teamClient' + +type MockResponse = { status: number; data: unknown } + +function createHttpMock(responses: MockResponse[]) { + const calls: Array<{ method: string; url: string; data?: unknown; headers?: Record }> = [] + const queue = [...responses] + const http = { + post: vi.fn(async (url: string) => { + calls.push({ method: 'post', url }) + return { status: 200, data: { token: 'jwt-1' } } + }), + request: vi.fn(async (config: { method: string; url: string; data?: unknown; headers?: Record }) => { + calls.push({ method: config.method, url: config.url, data: config.data, headers: config.headers }) + const next = queue.shift() + if (!next) throw new Error('no mocked response left') + return next + }) + } as unknown as AxiosInstance + return { http, calls } +} + +const statusBody: TeamStatusView = { + team: { id: 'team-1', name: 'Refactor auth', status: 'active', leadSessionId: 'sess-lead' }, + me: { sessionId: 'sess-lead', role: 'lead', status: 'idle' }, + members: [{ sessionId: 'sess-lead', role: 'lead', status: 'idle' }], + tasks: [], + pendingTasks: [], + budget: { maxMembers: 5, maxMessagesPerMinute: 30, maxChainDepth: 8 } +} + +describe('teamClient', () => { + const originalTeamId = process.env.HAPI_TEAM_ID + + afterEach(() => { + if (originalTeamId === undefined) { + delete process.env.HAPI_TEAM_ID + } else { + process.env.HAPI_TEAM_ID = originalTeamId + } + }) + + it('fetches status with bearer auth and returns null on 404', async () => { + const { http, calls } = createHttpMock([ + { status: 200, data: statusBody }, + { status: 404, data: { error: 'Session is not a member of any team' } } + ]) + const options = { sessionId: 'sess-lead', apiUrl: 'http://hub.test', accessToken: 'token', http } + + const status = await fetchTeamStatus(options) + expect(status?.team.id).toBe('team-1') + expect(calls[1]?.url).toBe('http://hub.test/api/teams/by-session/sess-lead') + expect(calls[1]?.headers?.Authorization).toBe('Bearer jwt-1') + + const missing = await fetchTeamStatus({ ...options, sessionId: 'sess-out' }) + expect(missing).toBeNull() + }) + + it('sends team messages with the caller session id', async () => { + const { http, calls } = createHttpMock([ + { status: 201, data: { message: { seq: 7, fromSessionId: 'sess-lead', fromKind: 'session', toSessionId: null, kind: 'chat', text: 'hi', createdAt: 1, meta: null } } } + ]) + + const message = await sendTeamMessage({ + sessionId: 'sess-lead', + teamId: 'team-1', + text: 'hi', + to: 'lead', + kind: 'status', + inReplyTo: 3, + apiUrl: 'http://hub.test', + accessToken: 'token', + http + }) + + expect(message.seq).toBe(7) + expect(calls[1]?.url).toBe('http://hub.test/api/teams/team-1/messages') + expect(calls[1]?.data).toEqual({ + fromSessionId: 'sess-lead', + text: 'hi', + to: 'lead', + kind: 'status', + inReplyTo: 3 + }) + }) + + it('formats status and message logs for the agent', () => { + const text = formatTeamStatus(statusBody) + expect(text).toContain('团队:Refactor auth') + expect(text).toContain('你的角色:lead') + expect(text).toContain('你的待办任务:无') + + const log = formatTeamMessages([ + { + seq: 2, + fromSessionId: 'sess-builder1234', + fromKind: 'session', + toSessionId: null, + kind: 'task-update', + text: 'T1 done', + createdAt: Date.UTC(2026, 0, 1, 10, 0, 0), + meta: null + } + ], 'sess-lead') + expect(log).toContain('#2') + expect(log).toContain('sess-bui') + expect(log).toContain('T1 done') + }) +}) diff --git a/cli/src/modules/team/teamClient.ts b/cli/src/modules/team/teamClient.ts new file mode 100644 index 00000000..b6423f1f --- /dev/null +++ b/cli/src/modules/team/teamClient.ts @@ -0,0 +1,303 @@ +/** + * Agent Team client used by the CLI / MCP tools. + * + * Talks to the hub's /api/teams endpoints with the same JWT flow as the web + * app (`POST /api/auth` with the CLI_API_TOKEN), scoped to the token's + * namespace. Never accepts an arbitrary host: always the configured hub. + */ + +import axios, { type AxiosInstance } from 'axios' + +import { configuration } from '@/configuration' +import { getAuthToken } from '@/api/auth' +import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' +import { exchangeJwt, resolveAccessToken, resolveApiUrl } from '@/modules/pingPeer/pingPeer' + +export class TeamClientError extends Error { + readonly code: 'not_in_team' | 'auth_failed' | 'request_failed' + + constructor(code: TeamClientError['code'], message: string) { + super(message) + this.name = 'TeamClientError' + this.code = code + } +} + +export interface TeamClientOptions { + sessionId: string + apiUrl?: string + accessToken?: string + http?: AxiosInstance + /** Per-request timeout; defaults to 15s. Startup probes use a shorter one. */ + timeoutMs?: number +} + +export interface TeamMemberView { + sessionId: string + role: string + status: string +} + +export interface TeamTaskView { + id: string + title: string + status: string + assigneeSessionId: string | null +} + +export interface TeamStatusView { + team: { id: string; name: string; status: string; leadSessionId: string | null } + me: { sessionId: string; role: string; status: string } + members: TeamMemberView[] + tasks: TeamTaskView[] + pendingTasks: TeamTaskView[] + budget: { maxMembers: number; maxMessagesPerMinute: number; maxChainDepth: number } +} + +export interface TeamMessageView { + seq: number + fromSessionId: string | null + fromKind: string + toSessionId: string | null + kind: string + text: string + createdAt: number + meta: Record | null +} + +interface SessionContext { + apiUrl: string + jwt: string + http: AxiosInstance + timeoutMs: number +} + +async function openSession(options: TeamClientOptions): Promise { + const apiUrl = resolveApiUrl(options.apiUrl ?? configuration.apiUrl) + const accessToken = resolveAccessToken(options.accessToken) + const http = options.http ?? axios + let jwt: string + try { + jwt = await exchangeJwt(apiUrl, accessToken, http) + } catch (error) { + throw new TeamClientError('auth_failed', error instanceof Error ? error.message : String(error)) + } + return { apiUrl, jwt, http, timeoutMs: options.timeoutMs ?? 15_000 } +} + +function headers(jwt: string): Record { + return buildHubRequestHeaders({ + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json' + }) +} + +async function request( + context: SessionContext, + method: 'get' | 'post' | 'patch', + path: string, + body?: unknown +): Promise { + let response + try { + response = await context.http.request({ + method, + url: `${context.apiUrl}${path}`, + headers: headers(context.jwt), + data: body, + timeout: context.timeoutMs, + validateStatus: () => true + }) + } catch (error) { + throw new TeamClientError('request_failed', error instanceof Error ? error.message : String(error)) + } + if (response.status >= 200 && response.status < 300) { + return response.data as T + } + const detail = typeof response.data?.error === 'string' ? response.data.error : 'request failed' + throw new TeamClientError('request_failed', `HTTP ${response.status}: ${detail}`) +} + +/** + * Fetch the team status for a session. Returns null when the session is not a + * team member (or the hub has the feature disabled / older than teams). + */ +export async function fetchTeamStatus(options: TeamClientOptions): Promise { + const context = await openSession(options) + try { + return await request( + context, + 'get', + `/api/teams/by-session/${encodeURIComponent(options.sessionId)}` + ) + } catch (error) { + if (error instanceof TeamClientError && error.code === 'request_failed' && /HTTP 404/.test(error.message)) { + return null + } + throw error + } +} + +/** + * Startup probe: does this hub build have the team feature enabled? + * 404 means disabled (or an older hub) -> the CLI must not register team + * tools, keeping non-team behavior byte-identical. Never throws. + */ +export async function probeTeamsSupport(): Promise { + try { + const context = await openSession({ sessionId: '', timeoutMs: 3000 }) + const response = await context.http.request({ + method: 'get', + url: `${context.apiUrl}/api/teams/feature`, + headers: headers(context.jwt), + timeout: context.timeoutMs, + validateStatus: () => true + }) + return response.status >= 200 && response.status < 300 + } catch { + return false + } +} + +/** + * Resolve the caller's current team membership fresh from the hub. Returns + * null when the session is not a member (callers must handle that). This is + * what makes team tools work for sessions that joined mid-life. + */ +export async function resolveCurrentTeam( + options: TeamClientOptions +): Promise { + try { + return await fetchTeamStatus(options) + } catch (error) { + if (error instanceof TeamClientError && error.code === 'request_failed' && /HTTP 404/.test(error.message)) { + return null + } + throw error + } +} + +export async function sendTeamMessage( + options: TeamClientOptions & { + teamId: string + text: string + to?: string + kind?: string + inReplyTo?: number + } +): Promise { + const context = await openSession(options) + const response = await request<{ message: TeamMessageView }>( + context, + 'post', + `/api/teams/${encodeURIComponent(options.teamId)}/messages`, + { + fromSessionId: options.sessionId, + text: options.text, + to: options.to, + kind: options.kind, + inReplyTo: options.inReplyTo + } + ) + return response.message +} + +export async function readTeamMessages( + options: TeamClientOptions & { teamId: string; afterSeq?: number; limit?: number } +): Promise { + const context = await openSession(options) + const params = new URLSearchParams({ sessionId: options.sessionId }) + if (options.afterSeq !== undefined) params.set('afterSeq', String(options.afterSeq)) + if (options.limit !== undefined) params.set('limit', String(options.limit)) + const response = await request<{ messages: TeamMessageView[] }>( + context, + 'get', + `/api/teams/${encodeURIComponent(options.teamId)}/messages?${params.toString()}` + ) + return response.messages +} + +export async function spawnTeamMember( + options: TeamClientOptions & { + teamId: string + role: string + task?: string + agent?: string + model?: string + sessionType?: 'simple' | 'worktree' + worktreeName?: string + } +): Promise<{ teamId: string; sessionId: string; role: string; taskId: string | null }> { + const context = await openSession(options) + return await request( + context, + 'post', + `/api/teams/${encodeURIComponent(options.teamId)}/spawn`, + { + fromSessionId: options.sessionId, + role: options.role, + task: options.task, + agent: options.agent, + model: options.model, + sessionType: options.sessionType, + worktreeName: options.worktreeName + } + ) +} + +export async function updateTeamTask( + options: TeamClientOptions & { teamId: string; taskId: string; status: 'todo' | 'doing' | 'done' | 'blocked' } +): Promise { + const context = await openSession(options) + await request( + context, + 'patch', + `/api/teams/${encodeURIComponent(options.teamId)}/tasks/${encodeURIComponent(options.taskId)}`, + { fromSessionId: options.sessionId, status: options.status } + ) +} + +// -------------------------------------------------------------- formatting + +export function formatTeamStatus(status: TeamStatusView): string { + const lines: string[] = [] + lines.push(`团队:${status.team.name}(${status.team.status}) · 你的角色:${status.me.role}`) + lines.push('') + lines.push(`成员(${status.members.length}/${status.budget.maxMembers}):`) + for (const member of status.members) { + const me = member.sessionId === status.me.sessionId ? ' ← 你' : '' + const lead = member.sessionId === status.team.leadSessionId ? ' [lead]' : '' + lines.push(`- ${member.role} (${member.sessionId.slice(0, 8)}) · ${member.status}${lead}${me}`) + } + if (status.pendingTasks.length > 0) { + lines.push('') + lines.push('你的待办任务:') + for (const task of status.pendingTasks) { + lines.push(`- ${task.title} [${task.status}] id=${task.id}`) + } + } else { + lines.push('') + lines.push('你的待办任务:无') + } + lines.push('') + lines.push(`限额:消息 ${status.budget.maxMessagesPerMinute}/分钟 · 链深 ${status.budget.maxChainDepth}`) + return lines.join('\n') +} + +export function formatTeamMessages(messages: TeamMessageView[], selfSessionId: string): string { + if (messages.length === 0) { + return '(没有新消息)' + } + return messages.map((message) => { + const from = message.fromKind === 'hub' + ? 'hub' + : message.fromKind === 'human' + ? '人类' + : message.fromSessionId === selfSessionId + ? '你' + : message.fromSessionId?.slice(0, 8) ?? '成员' + const to = message.toSessionId ? ` → ${message.toSessionId.slice(0, 8)}` : ' → 全员' + const time = new Date(message.createdAt).toISOString().slice(11, 19) + return `#${message.seq} [${time}] ${from}${to} (${message.kind}): ${message.text}` + }).join('\n') +} diff --git a/cli/src/modules/team/teamPrompt.test.ts b/cli/src/modules/team/teamPrompt.test.ts new file mode 100644 index 00000000..ec347352 --- /dev/null +++ b/cli/src/modules/team/teamPrompt.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { getTeamPromptBlock, withTeamInstruction } from './teamPrompt' + +describe('teamPrompt', () => { + it('returns null for non-team sessions', () => { + expect(getTeamPromptBlock('mcp__hapi__', {})).toBeNull() + }) + + it('renders the team block with the flavor tool prefix', () => { + const env = { HAPI_TEAM_ID: 'team-1', HAPI_TEAM_NAME: 'Refactor auth', HAPI_TEAM_ROLE: 'builder' } + const block = getTeamPromptBlock('functions.hapi__', env) + + expect(block).toContain('HAPI agent team "Refactor auth"') + expect(block).toContain('your role: builder') + expect(block).toContain('functions.hapi__team_status') + expect(block).toContain('functions.hapi__team_read') + expect(block).toContain('functions.hapi__team_send') + expect(block).toContain('functions.hapi__spawn_peer') + expect(block).not.toContain('mcp__hapi__') + }) + + it('appends the block only when a team id is present', () => { + expect(withTeamInstruction('BASE', 'mcp__hapi__', {})).toBe('BASE') + const appended = withTeamInstruction('BASE', 'mcp__hapi__', { HAPI_TEAM_ID: 't' }) + expect(appended.startsWith('BASE\n\n')).toBe(true) + expect(appended).toContain('mcp__hapi__team_status') + }) +}) diff --git a/cli/src/modules/team/teamPrompt.ts b/cli/src/modules/team/teamPrompt.ts new file mode 100644 index 00000000..a96fb23f --- /dev/null +++ b/cli/src/modules/team/teamPrompt.ts @@ -0,0 +1,30 @@ +import { trimIdent } from '@/utils/trimIdent' + +/** + * System-prompt block injected into team member sessions. + * + * Gated by HAPI_TEAM_ID (set by the runner when the hub spawns a member), so + * non-team sessions are byte-identical to before. Flavors without + * system-prompt injection (ACP) get the same rules via the hub's assignment + * brief instead. + */ +export function getTeamPromptBlock(toolPrefix: string, env: NodeJS.ProcessEnv = process.env): string | null { + const teamId = env.HAPI_TEAM_ID?.trim() + if (!teamId) { + return null + } + const name = env.HAPI_TEAM_NAME?.trim() || teamId.slice(0, 8) + const role = env.HAPI_TEAM_ROLE?.trim() || 'member' + return trimIdent(` + You are a member of HAPI agent team "${name}" (your role: ${role}). + - Start by calling ${toolPrefix}team_status to pick up your assignment, and ${toolPrefix}team_read to pull team messages (broadcasts are not pushed to you). + - Report progress, completion, and blockers with ${toolPrefix}team_send (kind=status or task-update). Batch updates; do not chat back and forth. + - Never use team_send for routine replies to the human - your normal replies are visible to them. Use ${toolPrefix}team_send with to="human" (or kind="decision") only when you genuinely need a human decision; it notifies them out-of-band. + - Only use ${toolPrefix}spawn_peer when the human or the lead explicitly asks you to. + `) +} + +export function withTeamInstruction(prompt: string, toolPrefix: string, env: NodeJS.ProcessEnv = process.env): string { + const block = getTeamPromptBlock(toolPrefix, env) + return block ? `${prompt}\n\n${block}` : prompt +} diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 3a0f582f..44872b61 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -641,6 +641,15 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): }; } + if (options.team?.id) { + extraEnv = { + ...extraEnv, + HAPI_TEAM_ID: options.team.id, + HAPI_TEAM_NAME: options.team.name, + HAPI_TEAM_ROLE: options.team.role + }; + } + const args = buildCliArgs(agent, options, yolo); // sessionId reserved for future use diff --git a/hub/src/config/serverSettings.test.ts b/hub/src/config/serverSettings.test.ts index 9f97682b..1c126192 100644 --- a/hub/src/config/serverSettings.test.ts +++ b/hub/src/config/serverSettings.test.ts @@ -11,9 +11,11 @@ function makeTempDir(): string { describe('loadServerSettings', () => { let dir: string | null = null const originalBackgroundOnly = process.env.SERVERCHAN_BACKGROUND_ONLY + const originalTeamsEnabled = process.env.TEAMS_ENABLED beforeEach(() => { delete process.env.SERVERCHAN_BACKGROUND_ONLY + delete process.env.TEAMS_ENABLED }) afterEach(() => { @@ -26,6 +28,11 @@ describe('loadServerSettings', () => { } else { process.env.SERVERCHAN_BACKGROUND_ONLY = originalBackgroundOnly } + if (originalTeamsEnabled === undefined) { + delete process.env.TEAMS_ENABLED + } else { + process.env.TEAMS_ENABLED = originalTeamsEnabled + } }) it('rejects old webapp settings fields instead of migrating them', async () => { @@ -144,4 +151,37 @@ describe('loadServerSettings', () => { expect(result.settings.iosPushMode).toBe('off') expect(result.sources.iosPushMode).toBe('file') }) + + it('defaults teamsEnabled to false without touching settings.json', async () => { + dir = makeTempDir() + delete process.env.TEAMS_ENABLED + + const result = await loadServerSettings(dir) + + expect(result.settings.teamsEnabled).toBe(false) + expect(result.sources.teamsEnabled).toBe('default') + expect(result.savedToFile).toBe(false) + }) + + it('reads teamsEnabled from settings.json when no env override is set', async () => { + dir = makeTempDir() + delete process.env.TEAMS_ENABLED + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ teamsEnabled: true })) + + const result = await loadServerSettings(dir) + + expect(result.settings.teamsEnabled).toBe(true) + expect(result.sources.teamsEnabled).toBe('file') + }) + + it('lets TEAMS_ENABLED override settings.json', async () => { + dir = makeTempDir() + process.env.TEAMS_ENABLED = 'true' + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ teamsEnabled: false })) + + const result = await loadServerSettings(dir) + + expect(result.settings.teamsEnabled).toBe(true) + expect(result.sources.teamsEnabled).toBe('env') + }) }) diff --git a/hub/src/config/serverSettings.ts b/hub/src/config/serverSettings.ts index 91e05b02..a4d47c6d 100644 --- a/hub/src/config/serverSettings.ts +++ b/hub/src/config/serverSettings.ts @@ -50,6 +50,7 @@ export interface ServerSettings { apnsTeamId: string | null apnsBundleId: string | null apnsEnv: string | null + teamsEnabled: boolean } export interface ServerSettingsResult { @@ -64,6 +65,7 @@ export interface ServerSettingsResult { listenPort: 'env' | 'file' | 'default' publicUrl: 'env' | 'file' | 'default' corsOrigins: 'env' | 'file' | 'default' + teamsEnabled: 'env' | 'file' | 'default' } & Record savedToFile: boolean } @@ -144,6 +146,7 @@ export async function loadServerSettings(dataDir: string): Promise file > null let telegramBotToken: string | null = null @@ -307,6 +310,20 @@ export async function loadServerSettings(dataDir: string): Promise file > false + let teamsEnabled = false + if (process.env.TEAMS_ENABLED !== undefined) { + teamsEnabled = process.env.TEAMS_ENABLED === 'true' + sources.teamsEnabled = 'env' + if (settings.teamsEnabled === undefined) { + settings.teamsEnabled = teamsEnabled + needsSave = true + } + } else if (settings.teamsEnabled !== undefined) { + teamsEnabled = settings.teamsEnabled + sources.teamsEnabled = 'file' + } + return { settings, write: needsSave, @@ -322,6 +339,7 @@ export async function loadServerSettings(dataDir: string): Promise { + const settings = await readSettingsOrThrow(getSettingsFile(dataDir)) + return isTeamsEnabledSetting(settings) +} + +export async function writeTeamsEnabled(dataDir: string, enabled: boolean): Promise { + return updateSettings(getSettingsFile(dataDir), (current) => { + const settings = { + ...current, + teamsEnabled: enabled + } + return { + settings, + result: settings.teamsEnabled === true + } + }) +} diff --git a/hub/src/configuration.ts b/hub/src/configuration.ts index e767bfcd..f4aa98e6 100644 --- a/hub/src/configuration.ts +++ b/hub/src/configuration.ts @@ -29,6 +29,8 @@ * direct-APNs credentials (settings: apnsKeyP8Path, apnsKeyId, apnsTeamId, apnsBundleId, apnsEnv) * - HAPI_HOME: Data directory (default: ~/.hapi) * - DB_PATH: SQLite database path (default: {HAPI_HOME}/hapi.db) + * - TEAMS_ENABLED: Enable the Agent Team feature (default: false) + * - TEAMS_DB_PATH: Agent Team database path (default: {HAPI_HOME}/teams.db) */ import { existsSync, mkdirSync } from 'node:fs' @@ -51,15 +53,13 @@ export interface ConfigSources { listenPort: ConfigSource publicUrl: ConfigSource corsOrigins: ConfigSource - androidPushMode: ConfigSource - fcmServiceAccountPath: ConfigSource - iosPushMode: ConfigSource iosPushRelayUrl: ConfigSource apnsKeyP8Path: ConfigSource apnsKeyId: ConfigSource apnsTeamId: ConfigSource apnsBundleId: ConfigSource apnsEnv: ConfigSource + teamsEnabled: ConfigSource cliApiToken: 'env' | 'file' | 'generated' } @@ -100,6 +100,12 @@ class Configuration { /** SQLite DB path */ public readonly dbPath: string + /** Whether the Agent Team feature is enabled (default false) */ + public readonly teamsEnabled: boolean + + /** Agent Team SQLite DB path (independent from dbPath) */ + public readonly teamsDbPath: string + /** Port for the HTTP service */ public readonly listenPort: number @@ -158,6 +164,10 @@ class Configuration { this.apnsTeamId = serverSettings.apnsTeamId this.apnsBundleId = serverSettings.apnsBundleId this.apnsEnv = serverSettings.apnsEnv + this.teamsEnabled = serverSettings.teamsEnabled + this.teamsDbPath = process.env.TEAMS_DB_PATH + ? process.env.TEAMS_DB_PATH.replace(/^~/, homedir()) + : join(dataDir, 'teams.db') // CLI API token - will be set by _setCliApiToken() before create() returns this.cliApiToken = '' @@ -167,7 +177,7 @@ class Configuration { // Store sources for logging (cliApiToken will be set by _setCliApiToken) this.sources = { ...sources, - } as ConfigSources + } as unknown as ConfigSources // Ensure data directory exists if (!existsSync(this.dataDir)) { diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index c2844388..618a526f 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test' import type { Session, SyncEvent, SyncEventListener, SyncEngine } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' -import type { NotificationChannel, TaskNotification } from './notificationTypes' +import type { NotificationChannel, TaskNotification, TeamAttentionNotification } from './notificationTypes' import { NotificationHub } from './notificationHub' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -35,6 +35,7 @@ class StubChannel implements NotificationChannel { readonly permissionSessions: Session[] = [] readonly taskNotifications: Array<{ session: Session; notification: TaskNotification }> = [] readonly sessionCompletions: Session[] = [] + readonly teamAttention: TeamAttentionNotification[] = [] async sendReady(session: Session): Promise { this.readySessions.push(session) @@ -51,6 +52,10 @@ class StubChannel implements NotificationChannel { async sendSessionCompletion(session: Session): Promise { this.sessionCompletions.push(session) } + + async sendTeamAttention(notification: TeamAttentionNotification): Promise { + this.teamAttention.push(notification) + } } function createSession(overrides: Partial = {}): Session { @@ -245,4 +250,40 @@ describe('NotificationHub', () => { hub.stop() }) + + it('forwards team attention events to channels that support them', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 5 + }) + + engine.emit({ + type: 'team-attention', + namespace: 'alpha', + teamId: 'team-1', + data: { + teamName: 'Refactor auth', + seq: 7, + kind: 'decision', + fromRole: 'Reviewer', + text: 'token TTL: config or constant?' + } + }) + await sleep(5) + + expect(channel.teamAttention).toHaveLength(1) + expect(channel.teamAttention[0]).toEqual({ + namespace: 'alpha', + teamId: 'team-1', + teamName: 'Refactor auth', + seq: 7, + kind: 'decision', + fromRole: 'Reviewer', + text: 'token TTL: config or constant?' + }) + + hub.stop() + }) }) diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index f9d627e1..19d48ac2 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -1,6 +1,6 @@ import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' -import type { NotificationChannel, NotificationHubOptions, TaskNotification } from './notificationTypes' +import type { NotificationChannel, NotificationHubOptions, TaskNotification, TeamAttentionNotification } from './notificationTypes' import type { NotificationSendContext } from './notificationSendContext' import { extractMessageEventType, extractTaskNotification } from './eventParsing' @@ -51,6 +51,13 @@ export class NotificationHub { return } + if (event.type === 'team-attention') { + this.sendTeamAttention(event).catch((error) => { + console.error('[NotificationHub] Failed to send team attention notification:', error) + }) + return + } + if (event.type === 'session-removed' && event.sessionId) { this.clearSessionState(event.sessionId) return @@ -227,4 +234,31 @@ export class NotificationHub { } } } + + private async sendTeamAttention(event: Extract): Promise { + const namespace = event.namespace + if (!namespace) { + return + } + const notification: TeamAttentionNotification = { + namespace, + teamId: event.teamId, + teamName: event.data.teamName, + seq: event.data.seq, + kind: event.data.kind, + fromRole: event.data.fromRole, + text: event.data.text + } + const ctx: NotificationSendContext = { nativeGate: { sent: false } } + for (const channel of this.channels) { + if (typeof channel.sendTeamAttention !== 'function') { + continue + } + try { + await channel.sendTeamAttention(notification, ctx) + } catch (error) { + console.error('[NotificationHub] Failed to send team attention notification:', error) + } + } + } } diff --git a/hub/src/notifications/notificationTypes.ts b/hub/src/notifications/notificationTypes.ts index 6130cf30..d4bcd264 100644 --- a/hub/src/notifications/notificationTypes.ts +++ b/hub/src/notifications/notificationTypes.ts @@ -7,11 +7,27 @@ export type TaskNotification = { status?: string } +/** + * A team message that needs the human's attention (explicit `to: human` or a + * `decision`). Delivered out-of-band by channels that support it. + */ +export type TeamAttentionNotification = { + namespace: string + teamId: string + teamName: string + seq: number + kind: string + fromRole: string + text: string +} + export type NotificationChannel = { sendReady: (session: Session, ctx?: NotificationSendContext) => Promise sendPermissionRequest: (session: Session, ctx?: NotificationSendContext) => Promise sendTaskNotification: (session: Session, notification: TaskNotification, ctx?: NotificationSendContext) => Promise sendSessionCompletion?: (session: Session, reason: SessionEndReason) => Promise + /** Optional: channels that cannot render team attention may omit this. */ + sendTeamAttention?: (notification: TeamAttentionNotification, ctx?: NotificationSendContext) => Promise } export type NotificationHubOptions = { diff --git a/hub/src/push/pushNotificationChannel.test.ts b/hub/src/push/pushNotificationChannel.test.ts index bd0d0c33..2160270e 100644 --- a/hub/src/push/pushNotificationChannel.test.ts +++ b/hub/src/push/pushNotificationChannel.test.ts @@ -191,4 +191,78 @@ describe('PushNotificationChannel', () => { expect(toasts).toHaveLength(0) expect(pushed).toHaveLength(0) }) + + it('renders team attention as a toast for visible clients and deep-links to the team page', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const toasts: Array<{ data: { title: string; body: string; url: string; sessionId: string } }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async (_namespace: string, event: unknown) => { + toasts.push(event as { data: { title: string; body: string; url: string; sessionId: string } }) + return 1 + } + } as never, + { + hasVisibleConnection: () => true + } as never, + '' + ) + + await channel.sendTeamAttention!({ + namespace: 'alpha', + teamId: 'team-1', + teamName: 'Refactor auth', + seq: 7, + kind: 'decision', + fromRole: 'Reviewer', + text: 'token TTL: config or constant?' + }) + + expect(pushed).toHaveLength(0) + expect(toasts).toHaveLength(1) + expect(toasts[0]?.data.title).toContain('Decision needed') + expect(toasts[0]?.data.title).toContain('Refactor auth') + expect(toasts[0]?.data.body).toContain('token TTL') + expect(toasts[0]?.data.url).toBe('/sessions/teams/team-1') + expect(toasts[0]?.data.sessionId).toBe('') + }) + + it('falls back to web push for team attention when no client is visible', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '' + ) + + await channel.sendTeamAttention!({ + namespace: 'alpha', + teamId: 'team-1', + teamName: 'Refactor auth', + seq: 8, + kind: 'chat', + fromRole: 'Builder A', + text: 'please review the interface' + }) + + expect(pushed).toHaveLength(1) + expect(pushed[0]?.namespace).toBe('alpha') + expect(pushed[0]?.payload.data?.type).toBe('team-attention') + expect(pushed[0]?.payload.data?.url).toBe('/sessions/teams/team-1') + expect(pushed[0]?.payload.title).toContain('Builder A') + }) }) diff --git a/hub/src/push/pushNotificationChannel.ts b/hub/src/push/pushNotificationChannel.ts index adc01fbb..0bc27207 100644 --- a/hub/src/push/pushNotificationChannel.ts +++ b/hub/src/push/pushNotificationChannel.ts @@ -1,5 +1,5 @@ import type { Session } from '../sync/syncEngine' -import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import type { NotificationChannel, TaskNotification, TeamAttentionNotification } from '../notifications/notificationTypes' import type { NotificationSendContext } from '../notifications/notificationSendContext' import { getAgentName, getSessionName } from '../notifications/sessionInfo' import type { SSEManager } from '../sse/sseManager' @@ -139,4 +139,52 @@ export class PushNotificationChannel implements NotificationChannel { private buildSessionPath(sessionId: string): string { return `/sessions/${sessionId}` } + + /** + * Team attention (@human / decision): reuse the session notification + * pattern - in-app toast when a client is visible, web push otherwise. + * There is no session context, so the toast/push payload carries an empty + * sessionId and deep-links to the team chat page. + */ + async sendTeamAttention(notification: TeamAttentionNotification, ctx?: NotificationSendContext): Promise { + if (ctx?.nativeGate?.sent) { + return + } + + const namespace = notification.namespace + const title = notification.kind === 'decision' + ? `Decision needed · ${notification.teamName}` + : `${notification.fromRole} needs you · ${notification.teamName}` + const body = notification.text.replace(/\s+/g, ' ').slice(0, 200) + const url = `/sessions/teams/${notification.teamId}` + const payload: PushPayload = { + title, + body, + tag: `team-${notification.teamId}`, + data: { + type: 'team-attention', + sessionId: '', + url + } + } + + if (this.visibilityTracker.hasVisibleConnection(namespace)) { + const delivered = await this.sseManager.sendToast(namespace, { + type: 'toast', + data: { + title, + body, + sessionId: '', + url + } + }) + if (delivered > 0) { + this.logBranch('team-attention', namespace, 'sse-toast-delivered', `count=${delivered}`) + return + } + } + + this.logBranch('team-attention', namespace, 'web-push-fired') + await this.pushService.sendToNamespace(namespace, payload) + } } diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 50443bea..7d11baa9 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -26,9 +26,14 @@ import { TunnelManager } from './tunnel' import { refreshRejectedRelayAuthKey, resolveRelayAuthKey } from './tunnel/relayAuth' import { waitForTunnelTlsReady } from './tunnel/tlsGate' import { ServerChanChannel } from './serverchan/channel' +import { TeamService, type TeamRuntime } from './teams/teamService' +import { TeamStore } from './teams/teamStore' import QRCode from 'qrcode' +import { join } from 'node:path' import type { Server as BunServer } from 'bun' import type { WebSocketData } from '@socket.io/bun-engine' +import { AgentFlavorSchema } from '@hapi/protocol' +import type { AgentFlavor } from '@hapi/protocol/types' /** Format config source for logging */ function formatSource(source: ConfigSource | 'generated'): string { @@ -212,6 +217,83 @@ export async function startHub(options: StartHubOptions = {}): Promise { + const session = syncEngine?.getSession(sessionId) + if (!session) return null + const flavor = session.metadata?.flavor + return { + id: session.id, + active: session.active, + thinking: session.thinking, + machineId: session.metadata?.machineId ?? null, + directory: session.metadata?.path ?? null, + flavor: AgentFlavorSchema.safeParse(flavor).success ? (flavor as AgentFlavor) : null, + inWorktree: session.metadata?.worktree != null + } + }, + spawnMember: async (input) => { + if (!syncEngine) { + return { ok: false, message: 'Hub is not ready' } + } + const result = await syncEngine.spawnSession( + input.machineId, + input.directory, + input.agent, + input.model, + undefined, + input.yolo === true, + input.sessionType, + input.worktreeName, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { id: input.teamId, name: input.teamName, role: input.teamRole } + ) + if (result.type === 'success') { + return { ok: true, sessionId: result.sessionId } + } + return { ok: false, message: result.message } + }, + deliverPeerMessage: async ({ sessionId, text }) => { + if (!syncEngine) { + throw new Error('Hub is not ready') + } + await syncEngine.sendMessage(sessionId, { text, sentFrom: 'team' }) + }, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + } + const teamService = config.teamsEnabled + ? new TeamService( + new TeamStore(config.teamsDbPath), + (event) => syncEngine?.publishEvent(event), + teamRuntime, + { memoryRoot: join(config.dataDir, 'teams') } + ) + : null + if (teamService) { + console.log(`[Hub] Agent Team: enabled (${config.teamsDbPath})`) + // Member session ended -> announce in the team log and wake the lead + // (or notify the human when the lead itself went down). + syncEngine.subscribe((event) => { + if (event.type !== 'session-ended' || !event.sessionId) { + return + } + const namespace = store.sessions.getSession(event.sessionId)?.namespace + if (!namespace) { + return + } + void teamService.handleSessionDown(event.sessionId, namespace, event.reason) + }) + } const notificationChannels: NotificationChannel[] = [] if (androidPushConfig.mode === 'fcm') { const { fcm } = androidPushConfig @@ -281,6 +363,7 @@ export async function startHub(options: StartHubOptions = {}): Promise syncEngine, getSseManager: () => sseManager, getVisibilityTracker: () => visibilityTracker, + getTeamService: () => teamService, jwtSecret, store, vapidPublicKey: vapidKeys.publicKey, @@ -397,6 +480,7 @@ export async function startHub(options: StartHubOptions = {}): Promise { + it('exposes the agentTeam capability when the runner advertises it', () => { + const { store, cache } = createCache() + store.machines.getOrCreateMachine( + 'machine-1', + BASE_METADATA, + { status: 'running', capabilities: { agentTeam: true } }, + 'ns' + ) + cache.reloadAll() + + expect(cache.getMachine('machine-1')?.runnerState?.capabilities?.agentTeam).toBe(true) + }) + + it('treats absence of the capability as unsupported', () => { + const { store, cache } = createCache() + store.machines.getOrCreateMachine( + 'machine-1', + BASE_METADATA, + { status: 'running', capabilities: { piExistingSessionResume: true } }, + 'ns' + ) + cache.reloadAll() + + expect(cache.getMachine('machine-1')?.runnerState?.capabilities?.agentTeam).toBeUndefined() + }) +}) + describe('MachineCache.renameMachine', () => { it('sets displayName without touching CLI-reported fields', async () => { const { store, cache } = createCache() diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index cd0cfbda..812c8bc0 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -844,7 +844,7 @@ export class MessageService { text: string localId?: string | null attachments?: AttachmentMetadata[] - sentFrom?: 'telegram-bot' | 'webapp' + sentFrom?: 'telegram-bot' | 'webapp' | 'team' scheduledAt?: number | null deliveryMode?: MessageDeliveryMode } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 1b33ffe2..54c2e89a 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -198,7 +198,10 @@ export class RpcGateway { // Hub session id to reuse for this spawn. When set, the runner boots the // CLI with `--hapi-session-id`, so the child reuses the existing hub // session row (same id) instead of minting a new one. - forkSession?: boolean + forkSession?: boolean, + // Agent Team context. The runner exports HAPI_TEAM_* env vars so the + // child CLI can inject team context and register team MCP tools. + team?: { id: string; name: string; role: string } ): Promise< | { type: 'success'; sessionId: string } | { @@ -230,7 +233,8 @@ export class RpcGateway { collaborationMode, copilotAgentMode, startingMode, - forkSession: forkSession === true + forkSession: forkSession === true, + ...(team ? { team } : {}) } ) if (result && typeof result === 'object') { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index ea8fe70f..52e501db 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -257,6 +257,15 @@ export class SyncEngine { return this.eventPublisher.subscribe(listener) } + /** + * Publish a hub-originated event through the standard pipeline: SSE + * broadcast (namespace-filtered) plus in-process subscribers such as the + * NotificationHub. Used by the Agent Team service. + */ + publishEvent(event: SyncEvent): void { + this.eventPublisher.emit(event) + } + private resolveNamespace(event: SyncEvent): string | undefined { if (event.namespace) { return event.namespace @@ -1026,7 +1035,7 @@ export class SyncEngine { path: string previewUrl?: string }> - sentFrom?: 'telegram-bot' | 'webapp' + sentFrom?: 'telegram-bot' | 'webapp' | 'team' scheduledAt?: number | null deliveryMode?: MessageDeliveryMode } @@ -2078,8 +2087,31 @@ export class SyncEngine { existingSessionId?: string, collaborationMode?: CodexCollaborationMode, copilotAgentMode?: CopilotAgentMode, - startingMode?: 'remote' | 'pty' + startingMode?: 'remote' | 'pty', + team?: { id: string; name: string; role: string } ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { + if (team) { + return await this.rpcGateway.spawnSession( + machineId, + directory, + agent, + model, + modelReasoningEffort, + yolo, + sessionType, + worktreeName, + resumeSessionId, + effort, + permissionMode, + serviceTier, + existingSessionId, + collaborationMode, + copilotAgentMode, + startingMode, + undefined, + team + ) + } return await this.rpcGateway.spawnSession( machineId, directory, diff --git a/hub/src/teams/teamIsolation.test.ts b/hub/src/teams/teamIsolation.test.ts new file mode 100644 index 00000000..c20a3924 --- /dev/null +++ b/hub/src/teams/teamIsolation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { Store } from '../store' +import { TeamStore } from './teamStore' + +/** + * Zero-impact acceptance: the team store lives in its own file and must never + * change the main hapi.db (schema version, rows). + */ +describe('Agent Team isolation from the main hub store', () => { + it('does not touch hapi.db when teams.db is created and used', () => { + const directory = mkdtempSync(join(tmpdir(), 'hapi-team-isolation-')) + const dbPath = join(directory, 'hapi.db') + const teamsDbPath = join(directory, 'teams.db') + let store: Store | undefined + let teams: TeamStore | undefined + try { + store = new Store(dbPath) + const session = store.sessions.getOrCreateSession( + 'sess-tag-1', + { path: '/tmp/project', host: 'test-host' }, + null, + 'default' + ) + const versionBefore = readUserVersion(dbPath) + store.close() + store = undefined + + teams = new TeamStore(teamsDbPath) + const team = teams.createTeam({ namespace: 'default', name: 'Refactor auth' }) + teams.addMember(team.id, session.id, 'builder') + teams.appendMessage({ + teamId: team.id, + fromKind: 'human', + toKind: 'broadcast', + text: 'kick off' + }) + teams.close() + teams = undefined + + expect(existsSync(teamsDbPath)).toBe(true) + + expect(readUserVersion(dbPath)).toBe(versionBefore) + + store = new Store(dbPath) + expect(store.sessions.getSession(session.id)?.metadata).toEqual({ path: '/tmp/project', host: 'test-host' }) + } finally { + teams?.close() + store?.close() + rmSync(directory, { recursive: true, force: true }) + } + }) +}) + +function readUserVersion(dbPath: string): number { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + try { + const row = db.prepare('PRAGMA user_version').get() as { user_version: number } + return row.user_version + } finally { + db.close() + } +} diff --git a/hub/src/teams/teamService.test.ts b/hub/src/teams/teamService.test.ts new file mode 100644 index 00000000..10e2a177 --- /dev/null +++ b/hub/src/teams/teamService.test.ts @@ -0,0 +1,622 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { SyncEvent } from '@hapi/protocol/types' + +import { TeamService, TeamServiceError, type TeamRuntime, type TeamSessionView, type TeamSpawnMemberInput } from './teamService' +import { TeamStore } from './teamStore' + +function createRuntime(overrides: Partial = {}) { + const delivered: Array<{ sessionId: string; text: string }> = [] + const spawnInputs: TeamSpawnMemberInput[] = [] + const sessions = new Map() + const runtime: TeamRuntime = { + resolveSession: (sessionId) => sessions.get(sessionId) ?? null, + spawnMember: async (input) => { + spawnInputs.push(input) + const sessionId = `sess-${input.teamRole}` + sessions.set(sessionId, { + id: sessionId, + active: true, + thinking: false, + machineId: input.machineId, + directory: input.directory, + flavor: input.agent, + inWorktree: false + }) + return { ok: true, sessionId } + }, + deliverPeerMessage: async (input) => { + delivered.push(input) + }, + sleep: async () => {}, + ...overrides + } + return { runtime, sessions, delivered, spawnInputs } +} + +function addCallerSession(sessions: Map, sessionId = 'sess-lead'): void { + sessions.set(sessionId, { + id: sessionId, + active: true, + thinking: false, + machineId: 'machine-1', + directory: '/repo', + flavor: 'claude', + inWorktree: false + }) +} + +async function flushAsync(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('TeamService membership', () => { + it('derives live member status from session activity', () => { + const { runtime, sessions } = createRuntime() + const service = new TeamService(new TeamStore(':memory:'), () => {}, runtime) + sessions.set('sess-lead', { + id: 'sess-lead', + active: true, + thinking: true, + machineId: 'machine-1', + directory: '/repo', + flavor: 'claude', + inWorktree: false + }) + try { + const team = service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + const store = service.getTeamDetail(team.id, 'alpha') + expect(store?.members).toHaveLength(1) + + const working = service.getStatusForSession('sess-lead', 'alpha') + expect(working.me.status).toBe('working') + + sessions.set('sess-lead', { ...sessions.get('sess-lead')!, thinking: false }) + expect(service.getStatusForSession('sess-lead', 'alpha').me.status).toBe('idle') + + sessions.delete('sess-lead') + expect(service.getStatusForSession('sess-lead', 'alpha').me.status).toBe('offline') + } finally { + service.close() + } + }) + + it('keeps explicitly blocked members blocked', () => { + const { runtime, sessions } = createRuntime() + const store = new TeamStore(':memory:') + const service = new TeamService(store, () => {}, runtime) + sessions.set('sess-lead', { + id: 'sess-lead', + active: true, + thinking: false, + machineId: 'machine-1', + directory: '/repo', + flavor: 'claude', + inWorktree: false + }) + try { + const team = service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + store.addMember(team.id, 'sess-builder', 'builder', 'blocked') + sessions.set('sess-builder', { + id: 'sess-builder', + active: true, + thinking: true, + machineId: 'machine-1', + directory: '/repo', + flavor: 'codex', + inWorktree: false + }) + + const status = service.getStatusForSession('sess-lead', 'alpha') + expect(status.members.find((member) => member.sessionId === 'sess-builder')?.status).toBe('blocked') + } finally { + service.close() + } + }) + + it('registers the lead as a member when provided at creation', () => { + const service = new TeamService(new TeamStore(':memory:')) + try { + const team = service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + const status = service.getStatusForSession('sess-lead', 'alpha') + expect(status.me.role).toBe('lead') + expect(status.team.id).toBe(team.id) + } finally { + service.close() + } + }) + + it('rejects non-members from status, messages and spawn', async () => { + const service = new TeamService(new TeamStore(':memory:')) + try { + service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + expect(() => service.getStatusForSession('sess-outsider', 'alpha')).toThrow(TeamServiceError) + await expect(service.sendMessage('sess-outsider', 'alpha', { text: 'hi' })).rejects.toThrow('not a member') + await expect(service.spawnMember('sess-outsider', 'alpha', { role: 'builder' })).rejects.toThrow('not a member') + } finally { + service.close() + } + }) +}) + +describe('TeamService messaging', () => { + function setup(budget?: Record) { + const events: SyncEvent[] = [] + const { runtime, sessions, delivered } = createRuntime() + const store = new TeamStore(':memory:') + const service = new TeamService(store, (event) => events.push(event), runtime) + addCallerSession(sessions) + const team = service.createTeam('alpha', { + name: 'Refactor auth', + leadSessionId: 'sess-lead', + config: budget ? { budget } : undefined + }) + store.addMember(team.id, 'sess-builder', 'builder') + sessions.set('sess-builder', { + id: 'sess-builder', + active: true, + thinking: false, + machineId: 'machine-1', + directory: '/repo', + flavor: 'codex', + inWorktree: false + }) + return { service, store, team, events, delivered, sessions } + } + + it('keeps broadcasts pull-only and pushes directed messages with a source prefix', async () => { + const { service, team, delivered } = setup() + try { + await service.sendMessage('sess-lead', 'alpha', { text: 'kick off', to: 'all' }) + expect(delivered).toHaveLength(0) + expect(service.listMessages('sess-lead', 'alpha')).toHaveLength(1) + + await service.sendMessage('sess-lead', 'alpha', { text: 'take T1', to: 'sess-buil' }) + expect(delivered).toHaveLength(1) + expect(delivered[0]?.sessionId).toBe('sess-builder') + expect(delivered[0]?.text).toContain('来自 lead') + expect(delivered[0]?.text).toContain('take T1') + + await service.sendMessage('sess-builder', 'alpha', { text: 'blocked', to: 'lead' }) + expect(delivered).toHaveLength(2) + expect(delivered[1]?.sessionId).toBe('sess-lead') + } finally { + service.close() + } + }) + + it('lets the human post into the channel and pushes directed messages', async () => { + const { service, delivered } = setup() + try { + const broadcast = await service.sendHumanMessage('alpha', service.listTeams('alpha')[0]!.id, { + text: 'status check' + }) + expect(broadcast.fromKind).toBe('human') + expect(delivered).toHaveLength(0) + + const directed = await service.sendHumanMessage('alpha', broadcast.teamId, { + text: 'focus on T1', + to: 'sess-buil' + }) + expect(directed.toKind).toBe('dm') + expect(delivered).toHaveLength(1) + expect(delivered[0]?.sessionId).toBe('sess-builder') + expect(delivered[0]?.text).toContain('来自 人类') + expect(delivered[0]?.text).toContain('focus on T1') + } finally { + service.close() + } + }) + + it('rejects human messages for unknown teams', async () => { + const { service } = setup() + try { + await expect(service.sendHumanMessage('alpha', 'missing-team', { text: 'hi' })) + .rejects.toThrow('Team not found') + } finally { + service.close() + } + }) + + it('routes to="human" as an attention signal without member push', async () => { + const { service, delivered, events } = setup() + try { + const message = await service.sendMessage('sess-builder', 'alpha', { text: 'need a call', to: 'human' }) + expect(message.toKind).toBe('mention') + expect(message.toSessionId).toBeNull() + expect(message.meta?.toHuman).toBe(true) + expect(delivered).toHaveLength(0) + expect(events.some((event) => event.type === 'team-attention')).toBe(true) + } finally { + service.close() + } + }) + + it('emits attention for decisions and rejects human self-targeting', async () => { + const { service, events } = setup() + try { + await service.sendMessage('sess-builder', 'alpha', { text: 'blocked on api', to: 'lead', kind: 'decision' }) + expect(events.some((event) => event.type === 'team-attention' && event.data.kind === 'decision')).toBe(true) + + await expect( + service.sendHumanMessage('alpha', service.listTeams('alpha')[0]!.id, { text: 'x', to: 'human' }) + ).rejects.toThrow('Humans cannot target themselves') + } finally { + service.close() + } + }) + + it('rejects self-targeting and ambiguous prefixes', async () => { + const { service, store, team } = setup() + try { + await expect(service.sendMessage('sess-lead', 'alpha', { text: 'x', to: 'sess-lead' })) + .rejects.toThrow('yourself') + store.addMember(team.id, 'sess-builder-2', 'builder-2') + await expect(service.sendMessage('sess-lead', 'alpha', { text: 'x', to: 'sess-buil' })) + .rejects.toThrow('Ambiguous') + } finally { + service.close() + } + }) + + it('enforces the per-minute message budget', async () => { + const { service } = setup({ maxMessagesPerMinute: 2 }) + try { + await service.sendMessage('sess-lead', 'alpha', { text: 'one' }) + await service.sendMessage('sess-lead', 'alpha', { text: 'two' }) + await expect(service.sendMessage('sess-lead', 'alpha', { text: 'three' })) + .rejects.toThrow('rate limit') + } finally { + service.close() + } + }) + + it('enforces conversation chain depth via inReplyTo', async () => { + const { service } = setup({ maxChainDepth: 2 }) + try { + const first = await service.sendMessage('sess-lead', 'alpha', { text: 'q' }) + const second = await service.sendMessage('sess-builder', 'alpha', { text: 'a1', inReplyTo: first.seq }) + const third = await service.sendMessage('sess-lead', 'alpha', { text: 'a2', inReplyTo: second.seq }) + await expect( + service.sendMessage('sess-builder', 'alpha', { text: 'a3', inReplyTo: third.seq }) + ).rejects.toThrow('chain depth') + } finally { + service.close() + } + }) +}) + +describe('TeamService spawning', () => { + it('spawns a member, records a task and delivers the assignment once active', async () => { + const { runtime, sessions, delivered } = createRuntime() + const events: SyncEvent[] = [] + const service = new TeamService(new TeamStore(':memory:'), (event) => events.push(event), runtime) + addCallerSession(sessions) + try { + const team = service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + const result = await service.spawnMember('sess-lead', 'alpha', { + role: 'builder', + task: 'Refactor the session layer', + agent: 'codex' + }) + expect(result.teamId).toBe(team.id) + expect(result.sessionId).toBe('sess-builder') + expect(result.taskId).toBeTruthy() + + await flushAsync() + expect(delivered).toHaveLength(1) + expect(delivered[0]?.sessionId).toBe('sess-builder') + expect(delivered[0]?.text).toContain('你的任务:Refactor the session layer') + expect(delivered[0]?.text).toContain('团队规范:') + expect(delivered[0]?.text).toContain('team_send') + + const status = service.getStatusForSession('sess-builder', 'alpha') + expect(status.pendingTasks).toHaveLength(1) + expect(status.me.role).toBe('builder') + expect(events.some((event) => event.type === 'team-updated')).toBe(true) + } finally { + service.close() + } + }) + + it('skips push when the member never becomes active but keeps the task pending', async () => { + const { runtime, sessions, delivered } = createRuntime() + runtime.spawnMember = async (input) => { + const sessionId = 'sess-slow' + runtime.resolveSession = () => ({ + id: sessionId, + active: false, + thinking: false, + machineId: input.machineId, + directory: input.directory, + flavor: input.agent, + inWorktree: false + }) + return { ok: true, sessionId } + } + const service = new TeamService(new TeamStore(':memory:'), () => {}, runtime) + addCallerSession(sessions) + try { + service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + await service.spawnMember('sess-lead', 'alpha', { role: 'builder', task: 'do it' }) + await flushAsync() + expect(delivered).toHaveLength(0) + const messages = service.listMessages('sess-lead', 'alpha') + expect(messages.some((message) => message.kind === 'system')).toBe(true) + } finally { + service.close() + } + }) + + it('passes yolo through to the runtime spawner', async () => { + const { runtime, sessions, spawnInputs } = createRuntime() + const service = new TeamService(new TeamStore(':memory:'), () => {}, runtime) + addCallerSession(sessions) + try { + service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + await service.spawnMember('sess-lead', 'alpha', { role: 'builder', yolo: true }) + expect(spawnInputs[0]?.yolo).toBe(true) + } finally { + service.close() + } + }) + + it('enforces member budget and duplicate roles', async () => { + const { runtime, sessions } = createRuntime() + const service = new TeamService(new TeamStore(':memory:'), () => {}, runtime) + addCallerSession(sessions) + try { + service.createTeam('alpha', { + name: 'Refactor auth', + leadSessionId: 'sess-lead', + config: { budget: { maxMembers: 3 } } + }) + await service.spawnMember('sess-lead', 'alpha', { role: 'builder' }) + await expect(service.spawnMember('sess-lead', 'alpha', { role: 'builder' })) + .rejects.toThrow('already exists') + await service.spawnMember('sess-lead', 'alpha', { role: 'reviewer' }) + await expect(service.spawnMember('sess-lead', 'alpha', { role: 'verifier' })) + .rejects.toThrow('Member limit reached') + } finally { + service.close() + } + }) + + it('propagates spawn failures', async () => { + const { runtime, sessions } = createRuntime() + runtime.spawnMember = async () => ({ ok: false, message: 'no machine online' }) + const service = new TeamService(new TeamStore(':memory:'), () => {}, runtime) + addCallerSession(sessions) + try { + service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + await expect(service.spawnMember('sess-lead', 'alpha', { role: 'builder' })) + .rejects.toThrow('no machine online') + } finally { + service.close() + } + }) +}) + +describe('TeamService memory', () => { + it('materializes charter.md + handoffs and lists/reads files', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-team-memory-')) + const service = new TeamService(new TeamStore(':memory:'), () => {}, null, { memoryRoot: dir }) + try { + const team = service.createTeam('alpha', { name: 'Refactor auth' }) + // createTeam materializes the dir fire-and-forget. + let files = await service.listMemoryFiles('alpha', team.id) + for (let attempt = 0; attempt < 50 && !files.some((file) => file.path === 'charter.md'); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + files = await service.listMemoryFiles('alpha', team.id) + } + expect(files.map((file) => file.path)).toContain('charter.md') + + const charter = await service.readMemoryFile('alpha', team.id, 'charter.md') + expect(charter.content).toContain('Refactor auth') + expect(charter.content).toContain('团队规约') + + await writeFile(join(dir, team.id, 'handoffs', 'T1.md'), 'done') + const after = await service.listMemoryFiles('alpha', team.id) + expect(after.some((file) => file.path === 'handoffs/T1.md')).toBe(true) + } finally { + service.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects traversal paths and unknown files', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-team-memory-')) + const service = new TeamService(new TeamStore(':memory:'), () => {}, null, { memoryRoot: dir }) + try { + const team = service.createTeam('alpha', { name: 'Refactor auth' }) + await expect(service.readMemoryFile('alpha', team.id, '../../etc/passwd')).rejects.toThrow('Invalid path') + await expect(service.readMemoryFile('alpha', team.id, '/etc/passwd')).rejects.toThrow('Invalid path') + await expect(service.readMemoryFile('alpha', team.id, 'missing.md')).rejects.toThrow('File not found') + await expect(service.listMemoryFiles('alpha', 'unknown-team')).rejects.toThrow('Team not found') + } finally { + service.close() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('TeamService web task management', () => { + function setup() { + const { runtime, sessions, delivered } = createRuntime() + const store = new TeamStore(':memory:') + const service = new TeamService(store, () => {}, runtime) + addCallerSession(sessions) + const team = service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + store.addMember(team.id, 'sess-builder', 'builder') + sessions.set('sess-builder', { + id: 'sess-builder', + active: true, + thinking: false, + machineId: 'machine-1', + directory: '/repo', + flavor: 'codex', + inWorktree: false + }) + return { service, store, team, delivered } + } + + it('creates tasks from the web, pushes the brief to the assignee', async () => { + const { service, team, delivered } = setup() + try { + const task = await service.createTaskForHuman('alpha', team.id, { + title: '写迁移脚本', + assigneeSessionId: 'sess-builder' + }) + expect(task.status).toBe('todo') + expect(delivered).toHaveLength(1) + expect(delivered[0]?.sessionId).toBe('sess-builder') + expect(delivered[0]?.text).toContain('你有一个新任务:写迁移脚本') + + const messages = service.listMessagesForHuman('alpha', team.id) + expect(messages.some((message) => message.kind === 'task-assign' && message.fromKind === 'human')).toBe(true) + + await expect(service.createTaskForHuman('alpha', team.id, { + title: 'bad', + assigneeSessionId: 'sess-outsider' + })).rejects.toThrow('not a team member') + } finally { + service.close() + } + }) + + it('updates tasks from the web without a session caller', async () => { + const { service, team } = setup() + try { + const task = await service.createTaskForHuman('alpha', team.id, { title: '审查接口' }) + const updated = await service.updateTask(null, 'alpha', task.id, { + status: 'doing', + assigneeSessionId: 'sess-builder' + }) + expect(updated.status).toBe('doing') + expect(updated.assigneeSessionId).toBe('sess-builder') + + const messages = service.listMessagesForHuman('alpha', team.id) + const last = messages[messages.length - 1] + expect(last?.fromKind).toBe('human') + expect(last?.text).toContain('负责人 → builder') + + await expect(service.updateTask(null, 'alpha', task.id, { assigneeSessionId: 'sess-outsider' })) + .rejects.toThrow('not a team member') + await expect(service.updateTask(null, 'alpha', 'missing', { status: 'done' })) + .rejects.toThrow('not found') + } finally { + service.close() + } + }) + + it('renames, archives and deletes teams from the web', async () => { + const { service, team } = setup() + try { + const renamed = service.updateTeamMeta('alpha', team.id, { name: 'Auth 重构 v2' }) + expect(renamed.name).toBe('Auth 重构 v2') + const archived = service.updateTeamMeta('alpha', team.id, { status: 'archived' }) + expect(archived.status).toBe('archived') + await expect(service.sendMessage('sess-builder', 'alpha', { text: 'hi' })).rejects.toThrow('archived') + + service.deleteTeam('alpha', team.id) + expect(service.getTeamDetail(team.id, 'alpha')).toBeNull() + expect(() => service.deleteTeam('alpha', team.id)).toThrow('Team not found') + } finally { + service.close() + } + }) +}) + +describe('TeamService member down handling', () => { + function setup() { + const { runtime, sessions, delivered } = createRuntime() + const events: SyncEvent[] = [] + const store = new TeamStore(':memory:') + const service = new TeamService(store, (event) => events.push(event), runtime) + addCallerSession(sessions) + const team = service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + store.addMember(team.id, 'sess-builder', 'builder', 'working') + sessions.set('sess-builder', { + id: 'sess-builder', + active: true, + thinking: false, + machineId: 'machine-1', + directory: '/repo', + flavor: 'codex', + inWorktree: false + }) + return { service, store, team, sessions, delivered, events } + } + + it('marks the member offline, logs it and wakes the lead', async () => { + const { service, store, team, delivered } = setup() + try { + await service.handleSessionDown('sess-builder', 'alpha', 'completed') + + const member = service.getTeamDetail(team.id, 'alpha')?.members.find((m) => m.sessionId === 'sess-builder') + expect(member?.status).toBe('offline') + const systemMessage = store.listMessages(team.id).find((message) => message.kind === 'system') + expect(systemMessage?.text).toContain('builder') + expect(systemMessage?.text).toContain('已离线') + expect(delivered).toHaveLength(1) + expect(delivered[0]?.sessionId).toBe('sess-lead') + expect(delivered[0]?.text).toContain('已离线') + } finally { + service.close() + } + }) + + it('dedupes repeated down events and ignores non-members', async () => { + const { service, delivered } = setup() + try { + await service.handleSessionDown('sess-builder', 'alpha', 'completed') + await service.handleSessionDown('sess-builder', 'alpha', 'completed') + expect(delivered).toHaveLength(1) + + await service.handleSessionDown('sess-outsider', 'alpha') + expect(delivered).toHaveLength(1) + } finally { + service.close() + } + }) + + it('notifies the human when the lead session goes down', async () => { + const { service, events } = setup() + try { + await service.handleSessionDown('sess-lead', 'alpha', 'terminated') + expect(events.some((event) => event.type === 'team-attention' && event.data.text.includes('已离线'))).toBe(true) + } finally { + service.close() + } + }) +}) + +describe('TeamService task updates', () => { + it('updates tasks and pushes done/blocked states to the lead', async () => { + const { runtime, sessions, delivered } = createRuntime() + const service = new TeamService(new TeamStore(':memory:'), () => {}, runtime) + addCallerSession(sessions) + try { + const team = service.createTeam('alpha', { name: 'Refactor auth', leadSessionId: 'sess-lead' }) + const spawned = await service.spawnMember('sess-lead', 'alpha', { role: 'builder', task: 'do it' }) + delivered.length = 0 + + const updated = await service.updateTask('sess-builder', 'alpha', spawned.taskId!, { status: 'done' }) + expect(updated.status).toBe('done') + expect(updated.teamId).toBe(team.id) + expect(delivered).toHaveLength(1) + expect(delivered[0]?.sessionId).toBe('sess-lead') + expect(delivered[0]?.text).toContain('done') + + await expect( + service.updateTask('sess-builder', 'alpha', 'missing-task', { status: 'done' }) + ).rejects.toThrow('not found') + } finally { + service.close() + } + }) +}) diff --git a/hub/src/teams/teamService.ts b/hub/src/teams/teamService.ts new file mode 100644 index 00000000..63a5c9b3 --- /dev/null +++ b/hub/src/teams/teamService.ts @@ -0,0 +1,977 @@ +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' +import type { AgentFlavor, SyncEvent } from '@hapi/protocol/types' + +import { + TeamStore, + type CreateTeamInput, + type TeamMemberRecord, + type TeamMessageRecord, + type TeamRecord, + type TeamTaskRecord, + type TeamTaskStatus +} from './teamStore' + +export interface TeamDetail { + team: TeamRecord + members: TeamMemberRecord[] + tasks: TeamTaskRecord[] +} + +export interface CreateTeamServiceInput { + name: string + leadSessionId?: string + config?: Record +} + +export interface TeamSessionView { + id: string + active: boolean + thinking: boolean + machineId: string | null + directory: string | null + flavor: AgentFlavor | null + inWorktree: boolean +} + +export interface TeamSpawnMemberInput { + machineId: string + directory: string + agent: AgentFlavor + model?: string + sessionType: 'simple' | 'worktree' + worktreeName?: string + yolo?: boolean + teamId: string + teamName: string + teamRole: string +} + +export interface TeamRuntime { + resolveSession(sessionId: string): TeamSessionView | null + spawnMember(input: TeamSpawnMemberInput): Promise<{ ok: true; sessionId: string } | { ok: false; message: string }> + deliverPeerMessage(input: { sessionId: string; text: string }): Promise + sleep(ms: number): Promise +} + +export type TeamServiceErrorCode = 'not_found' | 'forbidden' | 'budget' | 'invalid' | 'spawn_failed' + +export class TeamServiceError extends Error { + readonly code: TeamServiceErrorCode + + constructor(code: TeamServiceErrorCode, message: string) { + super(message) + this.name = 'TeamServiceError' + this.code = code + } +} + +export interface TeamMemoryFile { + path: string + size: number + updatedAt: number +} + +export interface TeamMemoryFileContent { + path: string + content: string + updatedAt: number +} + +export interface TeamStatus { + team: TeamRecord + me: TeamMemberRecord + members: TeamMemberRecord[] + tasks: TeamTaskRecord[] + pendingTasks: TeamTaskRecord[] + budget: TeamBudget +} + +export interface TeamBudget { + maxMembers: number + maxMessagesPerMinute: number + maxChainDepth: number +} + +const DEFAULT_BUDGET: TeamBudget = { + maxMembers: 5, + maxMessagesPerMinute: 30, + maxChainDepth: 8 +} + +const ACTIVATION_POLL_MS = 1000 +const ACTIVATION_POLL_ATTEMPTS = 30 + +/** + * P1 service: team membership, peer messaging with budget guards, and member + * spawning. All writes go through the hub; agents never write teams.db + * directly. + */ +export class TeamService { + private readonly store: TeamStore + private readonly publish: (event: SyncEvent) => void + private readonly runtime: TeamRuntime | null + private readonly memoryRoot: string | null + private readonly messageLog = new Map() + /** Sessions already announced as down, keyed `${teamId}:${sessionId}`. */ + private readonly downNotified = new Set() + + constructor( + store: TeamStore, + publish: (event: SyncEvent) => void = () => {}, + runtime: TeamRuntime | null = null, + options: { memoryRoot?: string } = {} + ) { + this.store = store + this.publish = publish + this.runtime = runtime + this.memoryRoot = options.memoryRoot ?? null + } + + // ------------------------------------------------------------ team basics + + listTeams(namespace: string): TeamRecord[] { + return this.store.listTeams(namespace) + } + + getTeamDetail(teamId: string, namespace: string): TeamDetail | null { + const team = this.store.getTeam(teamId, namespace) + if (!team) return null + return { + team, + members: this.store.listMembers(team.id), + tasks: this.store.listTasks(team.id) + } + } + + createTeam(namespace: string, input: CreateTeamServiceInput): TeamRecord { + const storeInput: CreateTeamInput = { + namespace, + name: input.name, + leadSessionId: input.leadSessionId ?? null, + config: input.config ?? null + } + const team = this.store.createTeam(storeInput) + if (input.leadSessionId) { + this.store.addMember(team.id, input.leadSessionId, 'lead') + } + this.publishUpdate(team) + // Fire-and-forget: materialize the team memory dir (charter.md + handoffs/). + void this.ensureTeamMemory(team).catch(() => {}) + return team + } + + resolveMembership(sessionId: string, namespace: string): { team: TeamRecord; member: TeamMemberRecord } | null { + return this.store.findTeamBySession(sessionId, namespace) + } + + getStatusForSession(sessionId: string, namespace: string): TeamStatus { + const membership = this.requireMembership(sessionId, namespace) + const tasks = this.store.listTasks(membership.team.id) + const members = this.store.listMembers(membership.team.id).map((member) => this.deriveMemberStatus(member)) + return { + team: membership.team, + me: this.deriveMemberStatus(membership.member), + members, + tasks, + pendingTasks: tasks.filter((task) => + task.assigneeSessionId === sessionId && (task.status === 'todo' || task.status === 'doing') + ), + budget: readBudget(membership.team.config) + } + } + + listMessages( + sessionId: string, + namespace: string, + options: { afterSeq?: number; limit?: number } = {} + ): TeamMessageRecord[] { + const membership = this.requireMembership(sessionId, namespace) + return this.store.listMessages(membership.team.id, options) + } + + /** Human (web app) read path: namespace-authorized, no membership required. */ + listMessagesForHuman( + namespace: string, + teamId: string, + options: { afterSeq?: number; limit?: number } = {} + ): TeamMessageRecord[] { + const team = this.store.getTeam(teamId, namespace) + if (!team) { + throw new TeamServiceError('not_found', 'Team not found') + } + return this.store.listMessages(team.id, options) + } + + // -------------------------------------------------------------- messaging + + async sendMessage( + sessionId: string, + namespace: string, + input: { text: string; to?: string; kind?: string; inReplyTo?: number } + ): Promise { + const membership = this.requireMembership(sessionId, namespace) + const { team, member } = membership + if (team.status !== 'active') { + throw new TeamServiceError('forbidden', 'Team is archived') + } + const members = this.store.listMembers(team.id) + const target = resolveTarget(team, members, input.to, { allowHuman: true }) + if (target.toSessionId === sessionId) { + throw new TeamServiceError('invalid', 'Cannot send a team message to yourself') + } + return await this.appendAndRoute(team, target, { + fromKind: 'session', + fromSessionId: sessionId, + fromRole: member.role, + text: input.text, + kind: input.kind, + inReplyTo: input.inReplyTo + }) + } + + /** Human (web app) posting into the team channel. */ + async sendHumanMessage( + namespace: string, + teamId: string, + input: { text: string; to?: string; kind?: string } + ): Promise { + const team = this.store.getTeam(teamId, namespace) + if (!team) { + throw new TeamServiceError('not_found', 'Team not found') + } + if (team.status !== 'active') { + throw new TeamServiceError('forbidden', 'Team is archived') + } + const members = this.store.listMembers(team.id) + const target = resolveTarget(team, members, input.to, { allowHuman: false }) + return await this.appendAndRoute(team, target, { + fromKind: 'human', + fromSessionId: null, + fromRole: '人类', + text: input.text, + kind: input.kind + }) + } + + private async appendAndRoute( + team: TeamRecord, + target: { toKind: 'broadcast' | 'mention' | 'dm'; toSessionId: string | null; toHuman?: boolean }, + input: { + fromKind: 'session' | 'human' | 'hub' + fromSessionId: string | null + fromRole: string + text: string + kind?: string + inReplyTo?: number + } + ): Promise { + const budget = readBudget(team.config) + this.enforceMessageRate(team.id, budget) + const replyDepth = this.resolveReplyDepth(team.id, input.inReplyTo, budget) + + const message = this.store.appendMessage({ + teamId: team.id, + fromKind: input.fromKind, + fromSessionId: input.fromSessionId, + toKind: target.toKind, + toSessionId: target.toSessionId, + kind: input.kind ?? 'chat', + text: input.text, + meta: { + fromRole: input.fromRole, + ...(target.toHuman ? { toHuman: true } : {}), + ...(replyDepth > 0 ? { replyDepth } : {}), + ...(input.inReplyTo ? { inReplyTo: input.inReplyTo } : {}) + } + }) + this.publishUpdate(team) + + // Human-facing escalation: an explicit `to: human` or a decision from a + // member notifies the human out-of-band (push / in-app toast). + if (input.fromKind === 'session' && (target.toHuman || message.kind === 'decision')) { + this.publish({ + type: 'team-attention', + teamId: team.id, + namespace: team.namespace, + data: { + teamName: team.name, + seq: message.seq, + kind: message.kind, + fromRole: input.fromRole, + text: input.text + } + }) + } + + // Push only for directed messages; broadcasts stay pull-only so they + // never fan out into every member's context. + if (target.toSessionId && this.runtime) { + const members = this.store.listMembers(team.id) + const text = this.formatPeerText(team, members, { + fromKind: input.fromKind, + fromSessionId: input.fromSessionId, + fromRole: input.fromRole, + text: input.text + }) + try { + await this.runtime.deliverPeerMessage({ sessionId: target.toSessionId, text }) + } catch { + // Delivery is best-effort; the message is already in the log and + // the target can pick it up via team_read. + } + } + return message + } + + /** + * Create a task from the web app. Assigning pushes the brief to the + * assignee so it can start working without waiting for a poll. + */ + async createTaskForHuman( + namespace: string, + teamId: string, + input: { title: string; assigneeSessionId?: string | null } + ): Promise { + const team = this.store.getTeam(teamId, namespace) + if (!team) { + throw new TeamServiceError('not_found', 'Team not found') + } + const members = this.store.listMembers(team.id) + const assigneeSessionId = input.assigneeSessionId ?? null + if (assigneeSessionId && !members.some((member) => member.sessionId === assigneeSessionId)) { + throw new TeamServiceError('invalid', 'Assignee is not a team member') + } + + const task = this.store.createTask({ + teamId: team.id, + title: input.title, + assigneeSessionId, + status: 'todo' + }) + const assigneeRole = members.find((member) => member.sessionId === assigneeSessionId)?.role + this.store.appendMessage({ + teamId: team.id, + fromKind: 'human', + fromSessionId: null, + toKind: assigneeSessionId ? 'task' : 'broadcast', + toSessionId: assigneeSessionId, + kind: 'task-assign', + text: input.title, + meta: { taskId: task.id, fromRole: '人类', ...(assigneeRole ? { role: assigneeRole } : {}) } + }) + this.publishUpdate(team) + + if (assigneeSessionId && this.runtime) { + try { + await this.runtime.deliverPeerMessage({ + sessionId: assigneeSessionId, + text: this.formatPeerText(team, members, { + fromKind: 'human', + fromSessionId: null, + fromRole: '人类', + text: `你有一个新任务:${input.title}\n任务 id:${task.id}` + }) + }) + } catch { + // Best-effort; the task board still shows it. + } + } + return task + } + + /** + * Update a task. `sessionId` present = member/CLI caller (membership + * checked); absent = human web caller (namespace checked). + */ + async updateTask( + sessionId: string | null, + namespace: string, + taskId: string, + patch: { status?: TeamTaskStatus; assigneeSessionId?: string | null } + ): Promise { + const membership = sessionId + ? this.requireMembership(sessionId, namespace) + : null + const task = this.store.getTask(taskId) + const team = membership?.team ?? (task ? this.store.getTeam(task.teamId, namespace) : null) + if (!task || !team || task.teamId !== team.id) { + throw new TeamServiceError('not_found', 'Task not found in this team') + } + + const members = this.store.listMembers(team.id) + if (patch.assigneeSessionId !== undefined && patch.assigneeSessionId !== null + && !members.some((member) => member.sessionId === patch.assigneeSessionId)) { + throw new TeamServiceError('invalid', 'Assignee is not a team member') + } + + const updated = this.store.updateTask(taskId, { + ...(patch.status !== undefined ? { status: patch.status } : {}), + ...(patch.assigneeSessionId !== undefined ? { assigneeSessionId: patch.assigneeSessionId } : {}) + }) + if (!updated) { + throw new TeamServiceError('not_found', 'Task not found in this team') + } + + const fromRole = membership?.member.role ?? '人类' + const changes: string[] = [] + if (patch.status !== undefined) changes.push(`→ ${patch.status}`) + if (patch.assigneeSessionId !== undefined) { + const assigneeRole = members.find((member) => member.sessionId === patch.assigneeSessionId)?.role + changes.push(`负责人 → ${assigneeRole ?? '未指派'}`) + } + this.store.appendMessage({ + teamId: team.id, + fromKind: membership ? 'session' : 'human', + fromSessionId: sessionId, + toKind: 'broadcast', + kind: 'task-update', + text: `任务「${updated.title}」${changes.join(',')}(${fromRole})`, + meta: { taskId: updated.id, fromRole } + }) + this.publishUpdate(team) + + // Done/blocked are lead-relevant: push those to the lead explicitly. + if (membership && patch.status !== undefined + && (patch.status === 'done' || patch.status === 'blocked') + && team.leadSessionId && team.leadSessionId !== sessionId && this.runtime) { + const text = this.formatPeerText(team, members, { + fromKind: 'session', + fromSessionId: sessionId, + fromRole, + text: `任务「${updated.title}」→ ${patch.status}` + }) + try { + await this.runtime.deliverPeerMessage({ sessionId: team.leadSessionId, text }) + } catch { + } + } + return updated + } + + /** Rename / archive a team from the web app. */ + updateTeamMeta(namespace: string, teamId: string, patch: { name?: string; status?: 'active' | 'archived' }): TeamRecord { + const updated = this.store.updateTeam(teamId, namespace, patch) + if (!updated) { + throw new TeamServiceError('not_found', 'Team not found') + } + this.publishUpdate(updated) + return updated + } + + /** Delete a team (members/tasks/messages cascade). Member sessions survive. */ + deleteTeam(namespace: string, teamId: string): void { + const team = this.store.getTeam(teamId, namespace) + if (!team) { + throw new TeamServiceError('not_found', 'Team not found') + } + this.store.deleteTeam(teamId, namespace) + this.publish({ type: 'team-updated', teamId, namespace }) + } + + // ---------------------------------------------------------------- spawning + + async spawnMember( + sessionId: string, + namespace: string, + input: { + role: string + task?: string + agent?: AgentFlavor + model?: string + sessionType?: 'simple' | 'worktree' + worktreeName?: string + yolo?: boolean + } + ): Promise<{ + teamId: string + sessionId: string + role: string + taskId: string | null + }> { + const membership = this.requireMembership(sessionId, namespace) + const { team } = membership + if (!this.runtime) { + throw new TeamServiceError('spawn_failed', 'Team runtime is not available on this hub') + } + if (team.status !== 'active') { + throw new TeamServiceError('forbidden', 'Team is archived') + } + + const members = this.store.listMembers(team.id) + const budget = readBudget(team.config) + if (members.length >= budget.maxMembers) { + throw new TeamServiceError('budget', `Member limit reached (${budget.maxMembers})`) + } + if (members.some((member) => member.role === input.role)) { + throw new TeamServiceError('invalid', `Role "${input.role}" already exists in this team`) + } + + const caller = this.runtime.resolveSession(sessionId) + if (!caller?.machineId || !caller.directory) { + throw new TeamServiceError('spawn_failed', 'Cannot resolve the calling session machine/directory') + } + + const sessionType = input.sessionType ?? (caller.inWorktree ? 'worktree' : 'simple') + const worktreeName = sessionType === 'worktree' + ? (input.worktreeName ?? defaultWorktreeName(team, input.role)) + : undefined + + const spawned = await this.runtime.spawnMember({ + machineId: caller.machineId, + directory: caller.directory, + agent: input.agent ?? caller.flavor ?? 'claude', + model: input.model, + sessionType, + worktreeName, + yolo: input.yolo === true, + teamId: team.id, + teamName: team.name, + teamRole: input.role + }) + if (!spawned.ok) { + throw new TeamServiceError('spawn_failed', spawned.message) + } + + const newSessionId = spawned.sessionId + this.store.addMember(team.id, newSessionId, input.role, 'working') + + let taskId: string | null = null + if (input.task) { + const task = this.store.createTask({ + teamId: team.id, + title: taskTitle(input.task), + assigneeSessionId: newSessionId, + status: 'todo', + meta: { brief: input.task } + }) + taskId = task.id + } + + this.store.appendMessage({ + teamId: team.id, + fromKind: 'hub', + toKind: 'task', + toSessionId: newSessionId, + kind: 'task-assign', + text: input.task ?? `新成员加入:${input.role}`, + meta: { role: input.role, sessionId: newSessionId, ...(taskId ? { taskId } : {}) } + }) + this.publishUpdate(team) + + if (input.task) { + const task = taskId ? this.store.getTask(taskId) : null + void this.deliverAssignment(team, newSessionId, input.role, input.task, task?.id ?? null) + } + return { teamId: team.id, sessionId: newSessionId, role: input.role, taskId } + } + + private async deliverAssignment( + team: TeamRecord, + targetSessionId: string, + role: string, + taskBrief: string, + taskId: string | null + ): Promise { + const runtime = this.runtime + if (!runtime) return + try { + let view = runtime.resolveSession(targetSessionId) + for (let attempt = 0; attempt < ACTIVATION_POLL_ATTEMPTS && !view?.active; attempt++) { + await runtime.sleep(ACTIVATION_POLL_MS) + view = runtime.resolveSession(targetSessionId) + } + if (!view?.active) { + this.store.appendMessage({ + teamId: team.id, + fromKind: 'hub', + toKind: 'task', + toSessionId: targetSessionId, + kind: 'system', + text: `成员 ${role} 尚未就绪,任务已记录,待其启动后通过 team_status 拉取` + }) + this.publishUpdate(team) + return + } + const members = this.store.listMembers(team.id) + const text = this.formatPeerText(team, members, { + fromKind: team.leadSessionId ? 'session' : 'hub', + fromSessionId: team.leadSessionId, + fromRole: 'lead', + text: buildAssignmentBrief(team, role, taskBrief, taskId, this.teamMemoryDir(team.id)) + }) + await runtime.deliverPeerMessage({ sessionId: targetSessionId, text }) + } catch { + // Best-effort; the task stays visible via team_status. + } + } + + // ----------------------------------------------------------------- memory + + /** Absolute path of a team's memory dir, or null when memory is disabled. */ + teamMemoryDir(teamId: string): string | null { + return this.memoryRoot ? join(this.memoryRoot, teamId) : null + } + + private async ensureTeamMemory(team: TeamRecord): Promise { + const dir = this.teamMemoryDir(team.id) + if (!dir) return + await mkdir(join(dir, 'handoffs'), { recursive: true }) + const charter = join(dir, 'charter.md') + try { + await stat(charter) + } catch { + await writeFile(charter, charterTemplate(team), 'utf8') + } + } + + async listMemoryFiles(namespace: string, teamId: string): Promise { + const team = this.store.getTeam(teamId, namespace) + if (!team) { + throw new TeamServiceError('not_found', 'Team not found') + } + const dir = this.teamMemoryDir(team.id) + if (!dir) return [] + + const files: TeamMemoryFile[] = [] + const walk = async (current: string, prefix: string, depth: number): Promise => { + if (depth > 3) return + let entries + try { + entries = await readdir(current, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + await walk(join(current, entry.name), relative, depth + 1) + continue + } + if (!entry.isFile()) continue + try { + const info = await stat(join(current, entry.name)) + files.push({ path: relative, size: info.size, updatedAt: info.mtimeMs }) + } catch { + } + } + } + await walk(dir, '', 1) + return files.sort((a, b) => a.path.localeCompare(b.path)) + } + + async readMemoryFile(namespace: string, teamId: string, relativePath: string): Promise { + const team = this.store.getTeam(teamId, namespace) + if (!team) { + throw new TeamServiceError('not_found', 'Team not found') + } + const dir = this.teamMemoryDir(team.id) + if (!dir) { + throw new TeamServiceError('not_found', 'File not found') + } + const normalized = sanitizeRelativePath(relativePath) + const root = resolve(dir) + const absolute = resolve(root, normalized) + if (absolute !== root && !absolute.startsWith(root + sep)) { + throw new TeamServiceError('invalid', 'Invalid path') + } + let info + try { + info = await stat(absolute) + } catch { + throw new TeamServiceError('not_found', 'File not found') + } + if (!info.isFile()) { + throw new TeamServiceError('not_found', 'File not found') + } + if (info.size > MAX_MEMORY_FILE_BYTES) { + throw new TeamServiceError('invalid', 'File is too large to preview') + } + let content: string + try { + content = await readFile(absolute, 'utf8') + } catch { + throw new TeamServiceError('invalid', 'File is not readable as text') + } + return { path: normalized, content, updatedAt: info.mtimeMs } + } + + /** + * A team member's session ended (CLI exited / archived). Announce it in the + * team log and wake the lead so work can be reassigned. When the lead + * itself goes down (or the team has no lead), escalate to the human. + */ + async handleSessionDown(sessionId: string, namespace: string, reason?: string): Promise { + const membership = this.store.findTeamBySession(sessionId, namespace) + if (!membership) return + const { team, member } = membership + if (team.status !== 'active') return + + const key = `${team.id}:${sessionId}` + if (this.downNotified.has(key)) return + this.downNotified.add(key) + if (member.status === 'offline') return + this.store.updateMemberStatus(team.id, sessionId, 'offline') + + const reasonText = reason ? `(${reason})` : '' + const text = `成员 ${member.role} 已离线:会话已结束${reasonText}` + const message = this.store.appendMessage({ + teamId: team.id, + fromKind: 'hub', + toKind: 'broadcast', + kind: 'system', + text, + meta: { sessionId, role: member.role, reason: reason ?? null } + }) + this.publishUpdate(team) + + if (!this.runtime) return + if (team.leadSessionId && team.leadSessionId !== sessionId) { + try { + const members = this.store.listMembers(team.id) + await this.runtime.deliverPeerMessage({ + sessionId: team.leadSessionId, + text: this.formatPeerText(team, members, { + fromKind: 'hub', + fromSessionId: null, + fromRole: 'hub', + text + }) + }) + } catch { + // Best-effort: the log message remains for the lead to pull. + } + return + } + + // Lead is down or absent: the human needs to know. + this.publish({ + type: 'team-attention', + teamId: team.id, + namespace: team.namespace, + data: { + teamName: team.name, + seq: message.seq, + kind: 'system', + fromRole: 'hub', + text + } + }) + } + + // ------------------------------------------------------------------ helpers + + private requireMembership(sessionId: string, namespace: string): { team: TeamRecord; member: TeamMemberRecord } { + const membership = this.store.findTeamBySession(sessionId, namespace) + if (!membership) { + throw new TeamServiceError('not_found', 'Session is not a member of any team') + } + return membership + } + + /** + * Live member status: blocked is sticky (explicitly reported), otherwise + * derive from the session's activity so the lead sees working/idle/offline + * without members having to report heartbeats. + */ + private deriveMemberStatus(member: TeamMemberRecord): TeamMemberRecord { + if (member.status === 'blocked' || !this.runtime) { + return member + } + const view = this.runtime.resolveSession(member.sessionId) + if (!view?.active) { + return { ...member, status: 'offline' } + } + return { ...member, status: view.thinking ? 'working' : 'idle' } + } + + private enforceMessageRate(teamId: string, budget: TeamBudget): void { + const now = Date.now() + const windowStart = now - 60_000 + const recent = (this.messageLog.get(teamId) ?? []).filter((timestamp) => timestamp > windowStart) + if (recent.length >= budget.maxMessagesPerMinute) { + throw new TeamServiceError( + 'budget', + `Team message rate limit reached (${budget.maxMessagesPerMinute}/min); summarize and batch instead` + ) + } + recent.push(now) + this.messageLog.set(teamId, recent) + } + + private resolveReplyDepth(teamId: string, inReplyTo: number | undefined, budget: TeamBudget): number { + if (!inReplyTo) return 0 + const parent = this.store.getMessage(teamId, inReplyTo) + const parentDepth = typeof parent?.meta?.replyDepth === 'number' ? parent.meta.replyDepth : 0 + const depth = parentDepth + 1 + if (depth > budget.maxChainDepth) { + throw new TeamServiceError( + 'budget', + `Conversation chain depth limit reached (${budget.maxChainDepth}); escalate to the human instead` + ) + } + return depth + } + + private formatPeerText( + team: TeamRecord, + members: TeamMemberRecord[], + message: { + fromKind: 'session' | 'human' | 'hub' + fromSessionId: string | null + fromRole?: string + text: string + } + ): string { + const fromRole = message.fromKind === 'human' + ? '人类' + : message.fromKind === 'hub' + ? 'hub' + : message.fromRole ?? members.find((member) => member.sessionId === message.fromSessionId)?.role ?? '成员' + const shortId = message.fromSessionId ? message.fromSessionId.slice(0, 8) : 'hub' + return `[团队消息 · ${team.name} · 来自 ${fromRole} (${shortId})]\n${message.text}` + } + + private publishUpdate(team: TeamRecord): void { + this.publish({ type: 'team-updated', teamId: team.id, namespace: team.namespace }) + } + + close(): void { + this.store.close() + } +} + +// -------------------------------------------------------------------- helpers + +function resolveTarget( + team: TeamRecord, + members: TeamMemberRecord[], + to: string | undefined, + options: { allowHuman: boolean } +): { toKind: 'broadcast' | 'mention' | 'dm'; toSessionId: string | null; toHuman?: boolean } { + if (!to || to === 'all' || to === 'broadcast') { + return { toKind: 'broadcast', toSessionId: null } + } + if (to === 'human') { + if (!options.allowHuman) { + throw new TeamServiceError('invalid', 'Humans cannot target themselves; use "all" or a member') + } + return { toKind: 'mention', toSessionId: null, toHuman: true } + } + if (to === 'lead') { + if (!team.leadSessionId) { + throw new TeamServiceError('invalid', 'This team has no lead session') + } + return { toKind: 'mention', toSessionId: team.leadSessionId } + } + const exact = members.find((member) => member.sessionId === to) + if (exact) { + return { toKind: 'dm', toSessionId: exact.sessionId } + } + const matches = members.filter((member) => member.sessionId.startsWith(to)) + if (matches.length === 0) { + throw new TeamServiceError('not_found', `No team member matches "${to}"`) + } + if (matches.length > 1) { + throw new TeamServiceError('invalid', `Ambiguous member prefix "${to}" (${matches.length} matches)`) + } + return { toKind: 'dm', toSessionId: matches[0]!.sessionId } +} + +function readBudget(config: Record | null): TeamBudget { + const raw = config?.budget + const budget = raw !== null && typeof raw === 'object' && !Array.isArray(raw) + ? raw as Record + : {} + return { + maxMembers: positiveInt(budget.maxMembers, DEFAULT_BUDGET.maxMembers), + maxMessagesPerMinute: positiveInt(budget.maxMessagesPerMinute, DEFAULT_BUDGET.maxMessagesPerMinute), + maxChainDepth: positiveInt(budget.maxChainDepth, DEFAULT_BUDGET.maxChainDepth) + } +} + +function positiveInt(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback +} + +function taskTitle(brief: string): string { + const firstLine = brief.split('\n')[0]?.trim() ?? '' + const title = firstLine.length > 0 ? firstLine : brief.trim() + return title.length > 120 ? `${title.slice(0, 117)}...` : title +} + +/** + * First prompt a spawned member receives. Doubles as the team-rules channel + * for flavors without system-prompt injection (ACP etc.). + */ +function buildAssignmentBrief( + team: TeamRecord, + role: string, + taskBrief: string, + taskId: string | null, + memoryDir: string | null +): string { + return [ + `你已被加入 HAPI 团队「${team.name}」,角色:${role}。`, + '', + `你的任务:${taskBrief}`, + ...(taskId ? [`任务 id:${taskId}`] : []), + ...(memoryDir ? ['', `团队记忆目录(hub 主机):${memoryDir}/(charter.md 是团队规约,交接产物写到 handoffs/)`] : []), + '', + '团队规范:', + '- 开工前可调用 team_read 拉取团队消息(广播不会主动推送)。', + '- 进展/完成/阻塞用 team_send 汇报(kind=status 或 task-update);完成后用 team_status 核对任务状态。', + '- 不要用 team_send 闲聊或找人类对话;批量汇报,避免来回对话。', + '- 需要人类决策时,用 team_send 的 to="human" 或 kind="decision"(会直接通知人类)。', + '- 你的普通回复会留在自己的会话里,人类和 lead 可随时查看。' + ].join('\n') +} + +function defaultWorktreeName(team: TeamRecord, role: string): string { + const teamSlug = slug(team.name) + const roleSlug = slug(role) + return teamSlug ? `${teamSlug}-${roleSlug}` : roleSlug +} + +const MAX_MEMORY_FILE_BYTES = 256 * 1024 + +function sanitizeRelativePath(value: string): string { + const normalized = value.replace(/\\/g, '/').trim() + if (!normalized || normalized.includes('\0')) { + throw new TeamServiceError('invalid', 'Invalid path') + } + const segments = normalized.split('/') + if (normalized.startsWith('/') || segments.some((segment) => segment === '' || segment === '..')) { + throw new TeamServiceError('invalid', 'Invalid path') + } + return normalized +} + +function charterTemplate(team: TeamRecord): string { + return [ + `# ${team.name} — 团队规约`, + '', + '> 本文件由 Hub 自动生成,团队成员与人类都可编辑。', + '', + '## 目标', + '(lead 或人类补充这个团队要达成什么)', + '', + '## 协作规则', + '- 广播消息不会主动推送:开工前用 team_read 拉取,完成后用 team_send 汇报。', + '- 需要人类决策时用 team_send 的 to="human" 或 kind="decision"。', + '- 交接产物写到 handoffs/ 目录,文件名用任务 id 或简短主题。', + '', + '## 决策记录', + '(重要取舍追加到这里,说明日期、背景、结论)', + '' + ].join('\n') +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) +} diff --git a/hub/src/teams/teamStore.test.ts b/hub/src/teams/teamStore.test.ts new file mode 100644 index 00000000..a07a2914 --- /dev/null +++ b/hub/src/teams/teamStore.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { TEAM_SCHEMA_VERSION, TeamStore } from './teamStore' + +function withStore(fn: (store: TeamStore, dbPath: string) => T): T { + const directory = mkdtempSync(join(tmpdir(), 'hapi-team-store-')) + const dbPath = join(directory, 'teams.db') + let store: TeamStore | undefined + try { + store = new TeamStore(dbPath) + return fn(store, dbPath) + } finally { + store?.close() + rmSync(directory, { recursive: true, force: true }) + } +} + +describe('TeamStore schema', () => { + it('creates the v1 schema in a dedicated file', () => { + withStore((store, dbPath) => { + expect(store.dbPath).toBe(dbPath) + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + const version = db.prepare('PRAGMA user_version').get() as { user_version: number } + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name" + ).all() as Array<{ name: string }> + db.close() + + expect(version.user_version).toBe(TEAM_SCHEMA_VERSION) + const names = tables.map(row => row.name) + expect(names).toContain('teams') + expect(names).toContain('team_members') + expect(names).toContain('team_tasks') + expect(names).toContain('team_messages') + }) + }) + + it('restricts file permissions to the owner', () => { + if (process.platform === 'win32') return + withStore((_store, dbPath) => { + const mode = statSync(dbPath).mode & 0o777 + expect(mode).toBe(0o600) + }) + }) + + it('refuses to open a newer schema version', () => { + const directory = mkdtempSync(join(tmpdir(), 'hapi-team-store-newer-')) + const dbPath = join(directory, 'teams.db') + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA user_version = 99') + db.close() + expect(() => new TeamStore(dbPath)).toThrow(/newer than supported/) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) +}) + +describe('TeamStore teams', () => { + it('creates, reads and lists teams per namespace', () => { + withStore(store => { + const alpha = store.createTeam({ namespace: 'alpha', name: 'Refactor auth' }) + store.createTeam({ namespace: 'beta', name: 'Other team' }) + + expect(alpha.status).toBe('active') + expect(alpha.leadSessionId).toBeNull() + expect(store.getTeam(alpha.id, 'alpha')?.name).toBe('Refactor auth') + expect(store.getTeam(alpha.id, 'beta')).toBeNull() + expect(store.listTeams('alpha')).toHaveLength(1) + expect(store.listTeams('beta')).toHaveLength(1) + }) + }) + + it('updates teams and honors archived status', () => { + withStore(store => { + const team = store.createTeam({ + namespace: 'alpha', + name: 'Refactor auth', + leadSessionId: 'sess-lead', + config: { template: 'refactor' } + }) + + const updated = store.updateTeam(team.id, 'alpha', { + status: 'archived', + config: { template: 'refactor', budget: { maxMembers: 6 } } + }) + expect(updated?.status).toBe('archived') + expect(updated?.config).toEqual({ template: 'refactor', budget: { maxMembers: 6 } }) + expect(updated?.leadSessionId).toBe('sess-lead') + expect(store.updateTeam(team.id, 'beta', { name: 'nope' })).toBeNull() + }) + }) + + it('deletes teams and cascades to members, tasks and messages', () => { + withStore(store => { + const team = store.createTeam({ namespace: 'alpha', name: 'Refactor auth' }) + store.addMember(team.id, 'sess-1', 'builder') + store.createTask({ teamId: team.id, title: 'Task 1' }) + store.appendMessage({ teamId: team.id, fromKind: 'human', toKind: 'broadcast', text: 'hello' }) + + expect(store.deleteTeam(team.id, 'alpha')).toBe(true) + expect(store.deleteTeam(team.id, 'alpha')).toBe(false) + expect(store.listMembers(team.id)).toHaveLength(0) + expect(store.listTasks(team.id)).toHaveLength(0) + expect(store.listMessages(team.id)).toHaveLength(0) + }) + }) +}) + +describe('TeamStore members', () => { + it('adds, updates and removes members', () => { + withStore(store => { + const team = store.createTeam({ namespace: 'alpha', name: 'Refactor auth' }) + store.addMember(team.id, 'sess-1', 'lead') + store.addMember(team.id, 'sess-2', 'builder') + + expect(store.listMembers(team.id).map(member => member.role)).toEqual(['lead', 'builder']) + expect(store.updateMemberStatus(team.id, 'sess-2', 'working')).toBe(true) + expect(store.listMembers(team.id)[1]?.status).toBe('working') + expect(store.removeMember(team.id, 'sess-1')).toBe(true) + expect(store.listMembers(team.id)).toHaveLength(1) + expect(store.removeMember(team.id, 'sess-missing')).toBe(false) + }) + }) +}) + +describe('TeamStore tasks', () => { + it('creates, lists and updates tasks', () => { + withStore(store => { + const team = store.createTeam({ namespace: 'alpha', name: 'Refactor auth' }) + const task = store.createTask({ + teamId: team.id, + title: 'session layer', + assigneeSessionId: 'sess-1', + meta: { files: ['src/auth/session.ts'] } + }) + + expect(store.listTasks(team.id)).toHaveLength(1) + const updated = store.updateTask(task.id, { status: 'done' }) + expect(updated?.status).toBe('done') + expect(updated?.meta).toEqual({ files: ['src/auth/session.ts'] }) + expect(store.updateTask('task-missing', { status: 'done' })).toBeNull() + }) + }) +}) + +describe('TeamStore messages', () => { + it('appends messages with monotonic seq and supports incremental reads', () => { + withStore(store => { + const team = store.createTeam({ namespace: 'alpha', name: 'Refactor auth' }) + const first = store.appendMessage({ + teamId: team.id, + fromKind: 'human', + toKind: 'broadcast', + text: 'kick off' + }) + const second = store.appendMessage({ + teamId: team.id, + fromKind: 'session', + fromSessionId: 'sess-1', + toKind: 'dm', + toSessionId: 'sess-2', + kind: 'task-assign', + text: 'take T1', + meta: { taskId: 't1' } + }) + + expect(second.seq).toBe(first.seq + 1) + expect(second.fromSessionId).toBe('sess-1') + expect(second.toSessionId).toBe('sess-2') + expect(second.kind).toBe('task-assign') + expect(store.listMessages(team.id)).toHaveLength(2) + expect(store.listMessages(team.id, { afterSeq: first.seq })).toHaveLength(1) + expect(store.listMessages(team.id, { limit: 1 })).toHaveLength(1) + }) + }) +}) + +describe('TeamStore persistence', () => { + it('keeps data across reopen', () => { + withStore((store, dbPath) => { + const team = store.createTeam({ namespace: 'alpha', name: 'Refactor auth' }) + store.appendMessage({ teamId: team.id, fromKind: 'human', toKind: 'broadcast', text: 'hello' }) + store.close() + + const reopened = new TeamStore(dbPath) + try { + expect(reopened.getTeam(team.id, 'alpha')?.name).toBe('Refactor auth') + expect(reopened.listMessages(team.id)).toHaveLength(1) + } finally { + reopened.close() + } + }) + }) +}) diff --git a/hub/src/teams/teamStore.ts b/hub/src/teams/teamStore.ts new file mode 100644 index 00000000..97f4c3df --- /dev/null +++ b/hub/src/teams/teamStore.ts @@ -0,0 +1,670 @@ +import { Database } from 'bun:sqlite' +import { chmodSync, closeSync, existsSync, mkdirSync, openSync } from 'node:fs' +import { dirname } from 'node:path' + +/** + * Independent SQLite store for the Agent Team feature (P0). + * + * This database is deliberately separate from the hub's main `hapi.db`: + * - the main store is never touched, so existing sessions/messages cannot be + * affected by team schema changes; + * - the file has its own `user_version` ladder, independent from the main + * store's SCHEMA_VERSION; + * - disabling the feature or deleting this file restores the previous state. + * + * Rows still reference session ids (plain TEXT, no cross-database foreign + * keys). Orphan cleanup is handled at the service layer. + */ + +export const TEAM_SCHEMA_VERSION: number = 1 + +const REQUIRED_TABLES = ['teams', 'team_members', 'team_tasks', 'team_messages'] as const + +export type TeamStatus = 'active' | 'archived' +export type TeamMemberStatus = 'idle' | 'working' | 'blocked' | 'offline' +export type TeamTaskStatus = 'todo' | 'doing' | 'done' | 'blocked' +export type TeamMessageFromKind = 'human' | 'session' | 'hub' +export type TeamMessageToKind = 'broadcast' | 'mention' | 'dm' | 'task' + +export interface TeamRecord { + id: string + namespace: string + name: string + status: TeamStatus + leadSessionId: string | null + config: Record | null + createdAt: number + updatedAt: number +} + +export interface TeamMemberRecord { + teamId: string + sessionId: string + role: string + status: TeamMemberStatus + joinedAt: number +} + +export interface TeamTaskRecord { + id: string + teamId: string + title: string + status: TeamTaskStatus + assigneeSessionId: string | null + meta: Record | null + createdAt: number + updatedAt: number +} + +export interface TeamMessageRecord { + seq: number + teamId: string + fromKind: TeamMessageFromKind + fromSessionId: string | null + toKind: TeamMessageToKind + toSessionId: string | null + kind: string + text: string + meta: Record | null + createdAt: number +} + +export interface CreateTeamInput { + namespace: string + name: string + id?: string + leadSessionId?: string | null + config?: Record | null +} + +export interface UpdateTeamInput { + name?: string + status?: TeamStatus + leadSessionId?: string | null + config?: Record | null +} + +export interface CreateTaskInput { + teamId: string + title: string + id?: string + status?: TeamTaskStatus + assigneeSessionId?: string | null + meta?: Record | null +} + +export interface UpdateTaskInput { + title?: string + status?: TeamTaskStatus + assigneeSessionId?: string | null + meta?: Record | null +} + +export interface AppendMessageInput { + teamId: string + fromKind: TeamMessageFromKind + fromSessionId?: string | null + toKind: TeamMessageToKind + toSessionId?: string | null + kind?: string + text: string + meta?: Record | null +} + +export interface ListMessagesOptions { + afterSeq?: number + limit?: number +} + +export class TeamStore { + private readonly db: Database + private readonly _dbPath: string + private closed: boolean = false + + get dbPath(): string { + return this._dbPath + } + + constructor(dbPath: string) { + this._dbPath = dbPath + if (dbPath !== ':memory:' && !dbPath.startsWith('file::memory:')) { + const dir = dirname(dbPath) + mkdirSync(dir, { recursive: true, mode: 0o700 }) + try { + chmodSync(dir, 0o700) + } catch { + } + + if (!existsSync(dbPath)) { + try { + const fd = openSync(dbPath, 'a', 0o600) + closeSync(fd) + } catch { + } + } + } + + this.db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + this.db.exec('PRAGMA journal_mode = WAL') + this.db.exec('PRAGMA synchronous = NORMAL') + this.db.exec('PRAGMA foreign_keys = ON') + this.db.exec('PRAGMA busy_timeout = 5000') + this.initSchema() + + if (dbPath !== ':memory:' && !dbPath.startsWith('file::memory:')) { + for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { + try { + chmodSync(path, 0o600) + } catch { + } + } + } + } + + close(): void { + if (this.closed) return + this.db.close() + this.closed = true + if (process.platform === 'win32') { + Bun.gc(true) + } + } + + // ---------------------------------------------------------------- teams + + createTeam(input: CreateTeamInput): TeamRecord { + const now = Date.now() + const record: TeamRecord = { + id: input.id ?? crypto.randomUUID(), + namespace: input.namespace, + name: input.name, + status: 'active', + leadSessionId: input.leadSessionId ?? null, + config: input.config ?? null, + createdAt: now, + updatedAt: now + } + this.db.prepare( + `INSERT INTO teams (id, namespace, name, status, lead_session_id, config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + record.id, + record.namespace, + record.name, + record.status, + record.leadSessionId, + toJson(record.config), + record.createdAt, + record.updatedAt + ) + return record + } + + getTeam(id: string, namespace: string): TeamRecord | null { + const row = this.db.prepare( + 'SELECT * FROM teams WHERE id = ? AND namespace = ?' + ).get(id, namespace) as TeamRow | undefined + return row ? mapTeamRow(row) : null + } + + listTeams(namespace: string): TeamRecord[] { + const rows = this.db.prepare( + 'SELECT * FROM teams WHERE namespace = ? ORDER BY updated_at DESC' + ).all(namespace) as TeamRow[] + return rows.map(mapTeamRow) + } + + updateTeam(id: string, namespace: string, patch: UpdateTeamInput): TeamRecord | null { + const current = this.getTeam(id, namespace) + if (!current) return null + + const next: TeamRecord = { + ...current, + name: patch.name ?? current.name, + status: patch.status ?? current.status, + leadSessionId: patch.leadSessionId !== undefined ? patch.leadSessionId : current.leadSessionId, + config: patch.config !== undefined ? patch.config : current.config, + updatedAt: Date.now() + } + this.db.prepare( + `UPDATE teams SET name = ?, status = ?, lead_session_id = ?, config = ?, updated_at = ? + WHERE id = ? AND namespace = ?` + ).run( + next.name, + next.status, + next.leadSessionId, + toJson(next.config), + next.updatedAt, + id, + namespace + ) + return next + } + + deleteTeam(id: string, namespace: string): boolean { + const result = this.db.prepare( + 'DELETE FROM teams WHERE id = ? AND namespace = ?' + ).run(id, namespace) + return result.changes > 0 + } + + // -------------------------------------------------------------- members + + addMember(teamId: string, sessionId: string, role: string, status: TeamMemberStatus = 'idle'): TeamMemberRecord { + const record: TeamMemberRecord = { + teamId, + sessionId, + role, + status, + joinedAt: Date.now() + } + this.db.prepare( + `INSERT INTO team_members (team_id, session_id, role, status, joined_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(team_id, session_id) DO UPDATE SET role = excluded.role, status = excluded.status` + ).run(record.teamId, record.sessionId, record.role, record.status, record.joinedAt) + return record + } + + removeMember(teamId: string, sessionId: string): boolean { + const result = this.db.prepare( + 'DELETE FROM team_members WHERE team_id = ? AND session_id = ?' + ).run(teamId, sessionId) + return result.changes > 0 + } + + listMembers(teamId: string): TeamMemberRecord[] { + const rows = this.db.prepare( + 'SELECT * FROM team_members WHERE team_id = ? ORDER BY joined_at ASC' + ).all(teamId) as TeamMemberRow[] + return rows.map(mapMemberRow) + } + + findTeamBySession(sessionId: string, namespace: string): { team: TeamRecord; member: TeamMemberRecord } | null { + const row = this.db.prepare( + `SELECT + t.id AS team_id, t.namespace AS team_namespace, t.name AS team_name, t.status AS team_status, + t.lead_session_id AS team_lead_session_id, t.config AS team_config, + t.created_at AS team_created_at, t.updated_at AS team_updated_at, + m.team_id AS member_team_id, m.session_id AS member_session_id, m.role AS member_role, + m.status AS member_status, m.joined_at AS member_joined_at + FROM team_members m + JOIN teams t ON t.id = m.team_id + WHERE m.session_id = ? AND t.namespace = ? + LIMIT 1` + ).get(sessionId, namespace) as TeamJoinRow | undefined + if (!row) return null + return { + team: { + id: row.team_id, + namespace: row.team_namespace, + name: row.team_name, + status: row.team_status === 'archived' ? 'archived' : 'active', + leadSessionId: row.team_lead_session_id, + config: parseJsonObject(row.team_config), + createdAt: row.team_created_at, + updatedAt: row.team_updated_at + }, + member: { + teamId: row.member_team_id, + sessionId: row.member_session_id, + role: row.member_role, + status: row.member_status === 'working' || row.member_status === 'blocked' || row.member_status === 'offline' + ? row.member_status + : 'idle', + joinedAt: row.member_joined_at + } + } + } + + updateMemberStatus(teamId: string, sessionId: string, status: TeamMemberStatus): boolean { + const result = this.db.prepare( + 'UPDATE team_members SET status = ? WHERE team_id = ? AND session_id = ?' + ).run(status, teamId, sessionId) + return result.changes > 0 + } + + // ---------------------------------------------------------------- tasks + + createTask(input: CreateTaskInput): TeamTaskRecord { + const now = Date.now() + const record: TeamTaskRecord = { + id: input.id ?? crypto.randomUUID(), + teamId: input.teamId, + title: input.title, + status: input.status ?? 'todo', + assigneeSessionId: input.assigneeSessionId ?? null, + meta: input.meta ?? null, + createdAt: now, + updatedAt: now + } + this.db.prepare( + `INSERT INTO team_tasks (id, team_id, title, status, assignee_session_id, meta, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + record.id, + record.teamId, + record.title, + record.status, + record.assigneeSessionId, + toJson(record.meta), + record.createdAt, + record.updatedAt + ) + return record + } + + getTask(id: string): TeamTaskRecord | null { + const row = this.db.prepare('SELECT * FROM team_tasks WHERE id = ?').get(id) as TeamTaskRow | undefined + return row ? mapTaskRow(row) : null + } + + listTasks(teamId: string): TeamTaskRecord[] { + const rows = this.db.prepare( + 'SELECT * FROM team_tasks WHERE team_id = ? ORDER BY created_at ASC' + ).all(teamId) as TeamTaskRow[] + return rows.map(mapTaskRow) + } + + updateTask(id: string, patch: UpdateTaskInput): TeamTaskRecord | null { + const current = this.getTask(id) + if (!current) return null + + const next: TeamTaskRecord = { + ...current, + title: patch.title ?? current.title, + status: patch.status ?? current.status, + assigneeSessionId: patch.assigneeSessionId !== undefined ? patch.assigneeSessionId : current.assigneeSessionId, + meta: patch.meta !== undefined ? patch.meta : current.meta, + updatedAt: Date.now() + } + this.db.prepare( + `UPDATE team_tasks SET title = ?, status = ?, assignee_session_id = ?, meta = ?, updated_at = ? + WHERE id = ?` + ).run( + next.title, + next.status, + next.assigneeSessionId, + toJson(next.meta), + next.updatedAt, + id + ) + return next + } + + // ------------------------------------------------------------- messages + + appendMessage(input: AppendMessageInput): TeamMessageRecord { + const now = Date.now() + const result = this.db.prepare( + `INSERT INTO team_messages + (team_id, from_kind, from_session_id, to_kind, to_session_id, kind, text, meta, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + input.teamId, + input.fromKind, + input.fromSessionId ?? null, + input.toKind, + input.toSessionId ?? null, + input.kind ?? 'chat', + input.text, + toJson(input.meta ?? null), + now + ) + return { + seq: Number(result.lastInsertRowid), + teamId: input.teamId, + fromKind: input.fromKind, + fromSessionId: input.fromSessionId ?? null, + toKind: input.toKind, + toSessionId: input.toSessionId ?? null, + kind: input.kind ?? 'chat', + text: input.text, + meta: input.meta ?? null, + createdAt: now + } + } + + listMessages(teamId: string, options: ListMessagesOptions = {}): TeamMessageRecord[] { + const afterSeq = options.afterSeq ?? 0 + const limit = Math.min(Math.max(options.limit ?? 500, 1), 2000) + const rows = this.db.prepare( + `SELECT * FROM team_messages WHERE team_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?` + ).all(teamId, afterSeq, limit) as TeamMessageRow[] + return rows.map(mapMessageRow) + } + + getMessage(teamId: string, seq: number): TeamMessageRecord | null { + const row = this.db.prepare( + 'SELECT * FROM team_messages WHERE team_id = ? AND seq = ?' + ).get(teamId, seq) as TeamMessageRow | undefined + return row ? mapMessageRow(row) : null + } + + // -------------------------------------------------------------- schema + + private initSchema(): void { + const currentVersion = this.getUserVersion() + + if (currentVersion === 0) { + this.createSchemaV1() + this.setUserVersion(1) + return + } + + if (currentVersion > TEAM_SCHEMA_VERSION) { + throw new Error( + `teams.db schema version ${currentVersion} is newer than supported ${TEAM_SCHEMA_VERSION}. ` + + 'Upgrade the hub or restore an older teams.db.' + ) + } + + // Future step migrations (V1 -> V2 -> ...) run here, following the main + // store's ladder pattern. None exist yet. + this.assertRequiredTablesPresent() + } + + private createSchemaV1(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + lead_session_id TEXT, + config TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_teams_namespace ON teams(namespace, updated_at); + + CREATE TABLE IF NOT EXISTS team_members ( + team_id TEXT NOT NULL, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle', + joined_at INTEGER NOT NULL, + PRIMARY KEY (team_id, session_id), + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_team_members_session ON team_members(session_id); + + CREATE TABLE IF NOT EXISTS team_tasks ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'todo', + assignee_session_id TEXT, + meta TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_team_tasks_team ON team_tasks(team_id, status); + + CREATE TABLE IF NOT EXISTS team_messages ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + team_id TEXT NOT NULL, + from_kind TEXT NOT NULL CHECK (from_kind IN ('human', 'session', 'hub')), + from_session_id TEXT, + to_kind TEXT NOT NULL CHECK (to_kind IN ('broadcast', 'mention', 'dm', 'task')), + to_session_id TEXT, + kind TEXT NOT NULL DEFAULT 'chat', + text TEXT NOT NULL, + meta TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_team_messages_team_seq ON team_messages(team_id, seq); + `) + } + + private assertRequiredTablesPresent(): void { + const rows = this.db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).all() as Array<{ name: string }> + const present = new Set(rows.map(row => row.name)) + for (const table of REQUIRED_TABLES) { + if (!present.has(table)) { + throw new Error(`teams.db is missing required table: ${table}`) + } + } + } + + private getUserVersion(): number { + const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number } | undefined + return row?.user_version ?? 0 + } + + private setUserVersion(version: number): void { + this.db.exec(`PRAGMA user_version = ${version}`) + } +} + +// ---------------------------------------------------------------- row types + +interface TeamRow { + id: string + namespace: string + name: string + status: string + lead_session_id: string | null + config: string | null + created_at: number + updated_at: number +} + +interface TeamMemberRow { + team_id: string + session_id: string + role: string + status: string + joined_at: number +} + +interface TeamTaskRow { + id: string + team_id: string + title: string + status: string + assignee_session_id: string | null + meta: string | null + created_at: number + updated_at: number +} + +interface TeamMessageRow { + seq: number + team_id: string + from_kind: string + from_session_id: string | null + to_kind: string + to_session_id: string | null + kind: string + text: string + meta: string | null + created_at: number +} + +interface TeamJoinRow { + team_id: string + team_namespace: string + team_name: string + team_status: string + team_lead_session_id: string | null + team_config: string | null + team_created_at: number + team_updated_at: number + member_team_id: string + member_session_id: string + member_role: string + member_status: string + member_joined_at: number +} + +function mapTeamRow(row: TeamRow): TeamRecord { + return { + id: row.id, + namespace: row.namespace, + name: row.name, + status: row.status === 'archived' ? 'archived' : 'active', + leadSessionId: row.lead_session_id, + config: parseJsonObject(row.config), + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +function mapMemberRow(row: TeamMemberRow): TeamMemberRecord { + return { + teamId: row.team_id, + sessionId: row.session_id, + role: row.role, + status: row.status === 'working' || row.status === 'blocked' || row.status === 'offline' ? row.status : 'idle', + joinedAt: row.joined_at + } +} + +function mapTaskRow(row: TeamTaskRow): TeamTaskRecord { + return { + id: row.id, + teamId: row.team_id, + title: row.title, + status: row.status === 'doing' || row.status === 'done' || row.status === 'blocked' ? row.status : 'todo', + assigneeSessionId: row.assignee_session_id, + meta: parseJsonObject(row.meta), + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +function mapMessageRow(row: TeamMessageRow): TeamMessageRecord { + return { + seq: row.seq, + teamId: row.team_id, + fromKind: row.from_kind === 'human' || row.from_kind === 'hub' ? row.from_kind : 'session', + fromSessionId: row.from_session_id, + toKind: row.to_kind === 'mention' || row.to_kind === 'dm' || row.to_kind === 'task' ? row.to_kind : 'broadcast', + toSessionId: row.to_session_id, + kind: row.kind, + text: row.text, + meta: parseJsonObject(row.meta), + createdAt: row.created_at + } +} + +function toJson(value: Record | null): string | null { + return value === null ? null : JSON.stringify(value) +} + +function parseJsonObject(value: string | null): Record | null { + if (value === null) return null + try { + const parsed: unknown = JSON.parse(value) + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null + } catch { + return null + } +} diff --git a/hub/src/web/routes/hubSettings.test.ts b/hub/src/web/routes/hubSettings.test.ts index 10ddfd6a..a5c8d182 100644 --- a/hub/src/web/routes/hubSettings.test.ts +++ b/hub/src/web/routes/hubSettings.test.ts @@ -34,7 +34,9 @@ describe('GET/PUT /api/hub-settings', () => { expect(response.headers.get('cache-control')).toBe('no-store') expect(await response.json()).toEqual({ sessionSummaryContract: false, - sessionSummaryInChat: false + sessionSummaryInChat: false, + teamsEnabled: false, + teamsEnabledActive: false }) }) @@ -48,13 +50,17 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(200) expect(await put.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: false + sessionSummaryInChat: false, + teamsEnabled: false, + teamsEnabledActive: false }) const get = await app.request('/api/hub-settings') expect(await get.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: false + sessionSummaryInChat: false, + teamsEnabled: false, + teamsEnabledActive: false }) }) @@ -70,7 +76,9 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(200) expect(await put.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: true + sessionSummaryInChat: true, + teamsEnabled: false, + teamsEnabledActive: false }) }) @@ -109,7 +117,9 @@ describe('GET/PUT /api/hub-settings', () => { expect(get.status).toBe(200) expect(await get.json()).toEqual({ sessionSummaryContract: false, - sessionSummaryInChat: true + sessionSummaryInChat: true, + teamsEnabled: false, + teamsEnabledActive: false }) const put = await tenantApp.request('/api/hub-settings', { @@ -120,14 +130,35 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(403) }) - it('survives a prior write via settings helpers', async () => { + it('persists the teams toggle and reports the active runtime value', async () => { + const { app } = await createApp() + const put = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ teamsEnabled: true }) + }) + expect(put.status).toBe(200) + expect(await put.json()).toEqual({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + teamsEnabled: true, + teamsEnabledActive: false + }) + + const stored = await (await app.request('/api/hub-settings')).json() as { teamsEnabled: boolean } + expect(stored.teamsEnabled).toBe(true) + }) + + it('survives a prior write via settings helper', async () => { const { app, dataDir } = await createApp() await writeSessionSummaryContractEnabled(dataDir, true) await writeSessionSummaryInChatEnabled(dataDir, true) const response = await app.request('/api/hub-settings') expect(await response.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: true + sessionSummaryInChat: true, + teamsEnabled: false, + teamsEnabledActive: false }) }) }) diff --git a/hub/src/web/routes/hubSettings.ts b/hub/src/web/routes/hubSettings.ts index 3d2a0877..a79ad797 100644 --- a/hub/src/web/routes/hubSettings.ts +++ b/hub/src/web/routes/hubSettings.ts @@ -6,18 +6,26 @@ import { updateSettings, type Settings } from '../../config/settings' +import { writeTeamsEnabled } from '../../config/teamsSetting' import type { WebAppEnv } from '../middleware/auth' const OWNER_ONLY_ERROR = 'Hub settings are only available to the hub owner' -function toHubSettings(settings: Settings): HubSettingsResponse { +function toHubSettings(settings: Settings, teamsEnabledActive: boolean): HubSettingsResponse { return { sessionSummaryContract: settings.sessionSummaryContract === true, - sessionSummaryInChat: settings.sessionSummaryInChat === true + sessionSummaryInChat: settings.sessionSummaryInChat === true, + // Persisted Agent Team gate; `teamsEnabledActive` reflects the running + // process (env override wins, file edits apply on restart). + teamsEnabled: settings.teamsEnabled === true, + teamsEnabledActive } } -export function createHubSettingsRoutes(dataDir: string): Hono { +export function createHubSettingsRoutes( + dataDir: string, + getActiveTeamsEnabled: () => boolean = () => false +): Hono { const app = new Hono() // Authenticated readers (any namespace) can observe hub-wide display/emit @@ -25,7 +33,7 @@ export function createHubSettingsRoutes(dataDir: string): Hono { app.get('/hub-settings', async (c) => { c.header('Cache-Control', 'no-store') const settings = await readSettingsOrThrow(getSettingsFile(dataDir)) - return c.json(toHubSettings(settings)) + return c.json(toHubSettings(settings, getActiveTeamsEnabled())) }) app.put('/hub-settings', async (c) => { @@ -37,6 +45,9 @@ export function createHubSettingsRoutes(dataDir: string): Hono { if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } + if (parsed.data.teamsEnabled !== undefined) { + await writeTeamsEnabled(dataDir, parsed.data.teamsEnabled) + } const response = await updateSettings(getSettingsFile(dataDir), (current) => { const settings: Settings = { ...current } if (parsed.data.sessionSummaryContract !== undefined) { @@ -47,7 +58,7 @@ export function createHubSettingsRoutes(dataDir: string): Hono { } return { settings, - result: toHubSettings(settings) + result: toHubSettings(settings, getActiveTeamsEnabled()) } }) c.header('Cache-Control', 'no-store') diff --git a/hub/src/web/routes/teams.test.ts b/hub/src/web/routes/teams.test.ts new file mode 100644 index 00000000..5ada546a --- /dev/null +++ b/hub/src/web/routes/teams.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Hono } from 'hono' +import type { SyncEvent } from '@hapi/protocol/types' + +import { TeamService, type TeamRuntime, type TeamSessionView } from '../../teams/teamService' +import { TeamStore } from '../../teams/teamStore' +import type { WebAppEnv } from '../middleware/auth' +import { createTeamsRoutes } from './teams' + +function createApp(getNamespace: () => string = () => 'alpha', memoryRoot?: string) { + const events: SyncEvent[] = [] + const sessions = new Map() + sessions.set('sess-lead', { + id: 'sess-lead', + active: true, + thinking: false, + machineId: 'machine-1', + directory: '/repo', + flavor: 'claude', + inWorktree: false + }) + const delivered: Array<{ sessionId: string; text: string }> = [] + const runtime: TeamRuntime = { + resolveSession: (sessionId) => sessions.get(sessionId) ?? null, + spawnMember: async (input) => { + const sessionId = `sess-${input.teamRole}` + sessions.set(sessionId, { + id: sessionId, + active: false, + thinking: false, + machineId: input.machineId, + directory: input.directory, + flavor: input.agent, + inWorktree: false + }) + return { ok: true, sessionId } + }, + deliverPeerMessage: async (input) => { + delivered.push(input) + }, + sleep: async () => {} + } + const service = new TeamService(new TeamStore(':memory:'), (event) => events.push(event), runtime, memoryRoot ? { memoryRoot } : {}) + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', getNamespace()) + await next() + }) + app.route('/api', createTeamsRoutes(service)) + return { app, events, service, delivered } +} + +describe('Agent Team member routes', () => { + async function createTeam(app: Hono): Promise { + const created = await app.request('/api/teams', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Refactor auth', leadSessionId: 'sess-lead' }) + }) + const body = await created.json() as { team: { id: string } } + return body.team.id + } + + it('resolves team membership by session', async () => { + const { app } = createApp() + const teamId = await createTeam(app) + + const mine = await app.request('/api/teams/by-session/sess-lead') + expect(mine.status).toBe(200) + const status = await mine.json() as { team: { id: string }; me: { role: string } } + expect(status.team.id).toBe(teamId) + expect(status.me.role).toBe('lead') + + const outsider = await app.request('/api/teams/by-session/sess-nobody') + expect(outsider.status).toBe(404) + }) + + it('sends team messages with push for directed targets', async () => { + const { app, delivered } = createApp() + const teamId = await createTeam(app) + + const broadcast = await app.request(`/api/teams/${teamId}/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromSessionId: 'sess-lead', text: 'kick off' }) + }) + expect(broadcast.status).toBe(201) + expect(delivered).toHaveLength(0) + + const directed = await app.request(`/api/teams/${teamId}/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromSessionId: 'sess-lead', text: 'go', to: 'sess-lead' }) + }) + expect(directed.status).toBe(400) + + const log = await app.request(`/api/teams/${teamId}/messages?sessionId=sess-lead`) + expect(log.status).toBe(200) + const body = await log.json() as { messages: unknown[] } + expect(body.messages).toHaveLength(1) + }) + + it('spawns members with budget enforcement', async () => { + const { app } = createApp() + const teamId = await createTeam(app) + + const spawned = await app.request(`/api/teams/${teamId}/spawn`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromSessionId: 'sess-lead', role: 'builder', task: 'do it' }) + }) + expect(spawned.status).toBe(201) + const body = await spawned.json() as { sessionId: string; taskId: string | null } + expect(body.sessionId).toBe('sess-builder') + expect(body.taskId).toBeTruthy() + + const leaderOnly = await app.request('/api/teams/unknown-team/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromSessionId: 'sess-lead', role: 'x' }) + }) + expect(leaderOnly.status).toBe(404) + }) + + it('lists and reads team memory files over HTTP', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-team-memory-routes-')) + try { + const { app } = createApp(() => 'alpha', dir) + const teamId = await createTeam(app) + await mkdir(join(dir, teamId, 'handoffs'), { recursive: true }) + await writeFile(join(dir, teamId, 'handoffs', 'note.md'), '# note') + // ensureTeamMemory runs fire-and-forget in createTeam; poll briefly. + let listBody: { files: Array<{ path: string }> } = { files: [] } + for (let attempt = 0; attempt < 50; attempt++) { + const list = await app.request(`/api/teams/${teamId}/memory`) + listBody = await list.json() as { files: Array<{ path: string }> } + if (listBody.files.some((file) => file.path === 'handoffs/note.md')) break + await new Promise((resolve) => setTimeout(resolve, 10)) + } + expect(listBody.files.map((file) => file.path)).toEqual(['charter.md', 'handoffs/note.md']) + + const file = await app.request(`/api/teams/${teamId}/memory/file?path=handoffs/note.md`) + expect(file.status).toBe(200) + expect((await file.json() as { content: string }).content).toBe('# note') + + const traversal = await app.request(`/api/teams/${teamId}/memory/file?path=../../etc/passwd`) + expect(traversal.status).toBe(400) + const missing = await app.request(`/api/teams/${teamId}/memory/file?path=nope.md`) + expect(missing.status).toBe(404) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('creates tasks, updates them as the human, and manages the team', async () => { + const { app, delivered } = createApp() + const teamId = await createTeam(app) + const spawned = await app.request(`/api/teams/${teamId}/spawn`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromSessionId: 'sess-lead', role: 'builder' }) + }) + expect(spawned.status).toBe(201) + delivered.length = 0 + + const created = await app.request(`/api/teams/${teamId}/tasks`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: '写迁移脚本', assigneeSessionId: 'sess-builder' }) + }) + expect(created.status).toBe(201) + const task = (await created.json() as { task: { id: string } }).task + expect(delivered).toHaveLength(1) + + const updated = await app.request(`/api/teams/${teamId}/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ status: 'doing', assigneeSessionId: 'sess-builder' }) + }) + expect(updated.status).toBe(200) + expect((await updated.json() as { task: { status: string } }).task.status).toBe('doing') + + const invalid = await app.request(`/api/teams/${teamId}/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}) + }) + expect(invalid.status).toBe(400) + + const renamed = await app.request(`/api/teams/${teamId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Auth 重构 v2' }) + }) + expect(renamed.status).toBe(200) + expect((await renamed.json() as { team: { name: string } }).team.name).toBe('Auth 重构 v2') + + const removed = await app.request(`/api/teams/${teamId}`, { method: 'DELETE' }) + expect(removed.status).toBe(200) + const missing = await app.request(`/api/teams/${teamId}`) + expect(missing.status).toBe(404) + }) + + it('updates task status', async () => { + const { app } = createApp() + const teamId = await createTeam(app) + const spawned = await app.request(`/api/teams/${teamId}/spawn`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromSessionId: 'sess-lead', role: 'builder', task: 'do it' }) + }) + const { taskId } = await spawned.json() as { taskId: string } + + const updated = await app.request(`/api/teams/${teamId}/tasks/${taskId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromSessionId: 'sess-lead', status: 'doing' }) + }) + expect(updated.status).toBe(200) + expect((await updated.json() as { task: { status: string } }).task.status).toBe('doing') + + const missing = await app.request(`/api/teams/${teamId}/tasks/nope`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromSessionId: 'sess-lead', status: 'done' }) + }) + expect(missing.status).toBe(404) + }) +}) + +describe('Agent Team routes', () => { + it('starts empty and creates a team', async () => { + const { app, events } = createApp() + + const empty = await app.request('/api/teams') + expect(empty.status).toBe(200) + expect(await empty.json()).toEqual({ teams: [] }) + + const created = await app.request('/api/teams', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Refactor auth' }) + }) + expect(created.status).toBe(201) + const body = await created.json() as { team: { id: string; name: string; namespace: string } } + expect(body.team.name).toBe('Refactor auth') + expect(body.team.namespace).toBe('alpha') + + const list = await app.request('/api/teams') + expect(await list.json()).toEqual({ teams: [expect.objectContaining({ id: body.team.id })] }) + + const detail = await app.request(`/api/teams/${body.team.id}`) + expect(detail.status).toBe(200) + expect(await detail.json()).toEqual(expect.objectContaining({ + team: expect.objectContaining({ id: body.team.id }), + members: [], + tasks: [] + })) + + expect(events).toEqual([{ type: 'team-updated', teamId: body.team.id, namespace: 'alpha' }]) + }) + + it('rejects invalid bodies', async () => { + const { app } = createApp() + const response = await app.request('/api/teams', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: '' }) + }) + expect(response.status).toBe(400) + }) + + it('does not leak teams across namespaces', async () => { + let namespace = 'alpha' + const { app } = createApp(() => namespace) + + const created = await app.request('/api/teams', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Refactor auth' }) + }) + const body = await created.json() as { team: { id: string } } + + namespace = 'beta' + expect(await (await app.request('/api/teams')).json()).toEqual({ teams: [] }) + expect((await app.request(`/api/teams/${body.team.id}`)).status).toBe(404) + + namespace = 'alpha' + expect(await (await app.request('/api/teams')).json()).toEqual({ + teams: [expect.objectContaining({ id: body.team.id })] + }) + }) +}) diff --git a/hub/src/web/routes/teams.ts b/hub/src/web/routes/teams.ts new file mode 100644 index 00000000..d11f5690 --- /dev/null +++ b/hub/src/web/routes/teams.ts @@ -0,0 +1,285 @@ +import { Hono } from 'hono' +import type { Context } from 'hono' +import { + CreateTeamRequestSchema, + CreateTeamTaskRequestSchema, + TeamHumanMessageRequestSchema, + TeamSendMessageRequestSchema, + TeamSpawnMemberRequestSchema, + TeamTaskUpdateRequestSchema, + UpdateTeamRequestSchema +} from '@hapi/protocol' + +import { TeamService, TeamServiceError } from '../../teams/teamService' +import type { WebAppEnv } from '../middleware/auth' + +/** + * Agent Team API. Registered only when the hub has `teamsEnabled` — with the + * feature off these paths do not exist (404), matching pre-feature behavior. + */ +export function createTeamsRoutes(teams: TeamService): Hono { + const app = new Hono() + + app.get('/teams', (c) => { + c.header('Cache-Control', 'no-store') + return c.json({ teams: teams.listTeams(c.get('namespace')) }) + }) + + // Feature probe for CLI startup: this route only exists when teams are + // enabled, so 200 vs 404 tells the CLI whether to register team tools. + // Must be registered before /teams/:id (Hono matches in order). + app.get('/teams/feature', (c) => { + c.header('Cache-Control', 'no-store') + return c.json({ enabled: true }) + }) + + app.get('/teams/by-session/:sessionId', (c) => { + c.header('Cache-Control', 'no-store') + const sessionId = c.req.param('sessionId') + try { + return c.json(teams.getStatusForSession(sessionId, c.get('namespace'))) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.get('/teams/:id', (c) => { + c.header('Cache-Control', 'no-store') + const detail = teams.getTeamDetail(c.req.param('id'), c.get('namespace')) + if (!detail) { + return c.json({ error: 'Team not found' }, 404) + } + return c.json(detail) + }) + + app.get('/teams/:id/messages', (c) => { + c.header('Cache-Control', 'no-store') + const sessionId = c.req.query('sessionId') + const afterSeq = parsePositiveInt(c.req.query('afterSeq')) + const limit = parsePositiveInt(c.req.query('limit')) + try { + // sessionId present -> CLI/MCP caller (membership checked); + // absent -> human web reader (namespace-authorized). + const messages = sessionId + ? teams.listMessages(sessionId, c.get('namespace'), { afterSeq, limit }) + : teams.listMessagesForHuman(c.get('namespace'), c.req.param('id'), { afterSeq, limit }) + return c.json({ messages }) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.post('/teams/:id/messages', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = TeamSendMessageRequestSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + const teamId = c.req.param('id') + const membership = teams.resolveMembership(parsed.data.fromSessionId, c.get('namespace')) + if (!membership || membership.team.id !== teamId) { + return c.json({ error: 'Session is not a member of this team' }, 404) + } + try { + const message = await teams.sendMessage(parsed.data.fromSessionId, c.get('namespace'), { + text: parsed.data.text, + to: parsed.data.to, + kind: parsed.data.kind, + inReplyTo: parsed.data.inReplyTo + }) + c.header('Cache-Control', 'no-store') + return c.json({ message }, 201) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.post('/teams/:id/spawn', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = TeamSpawnMemberRequestSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + const teamId = c.req.param('id') + const membership = teams.resolveMembership(parsed.data.fromSessionId, c.get('namespace')) + if (!membership || membership.team.id !== teamId) { + return c.json({ error: 'Session is not a member of this team' }, 404) + } + if (!membership.member.role) { + return c.json({ error: 'Member role is required to spawn peers' }, 403) + } + try { + const result = await teams.spawnMember(parsed.data.fromSessionId, c.get('namespace'), { + role: parsed.data.role, + task: parsed.data.task, + agent: parsed.data.agent, + model: parsed.data.model, + sessionType: parsed.data.sessionType, + worktreeName: parsed.data.worktreeName, + yolo: parsed.data.yolo + }) + c.header('Cache-Control', 'no-store') + return c.json(result, 201) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.post('/teams/:id/tasks', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = CreateTeamTaskRequestSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + try { + const task = await teams.createTaskForHuman(c.get('namespace'), c.req.param('id'), { + title: parsed.data.title, + assigneeSessionId: parsed.data.assigneeSessionId ?? null + }) + c.header('Cache-Control', 'no-store') + return c.json({ task }, 201) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.patch('/teams/:id/tasks/:taskId', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = TeamTaskUpdateRequestSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + const teamId = c.req.param('id') + const fromSessionId = parsed.data.fromSessionId ?? null + if (fromSessionId) { + const membership = teams.resolveMembership(fromSessionId, c.get('namespace')) + if (!membership || membership.team.id !== teamId) { + return c.json({ error: 'Session is not a member of this team' }, 404) + } + } + try { + const task = await teams.updateTask( + fromSessionId, + c.get('namespace'), + c.req.param('taskId'), + { + ...(parsed.data.status !== undefined ? { status: parsed.data.status } : {}), + ...(parsed.data.assigneeSessionId !== undefined ? { assigneeSessionId: parsed.data.assigneeSessionId } : {}) + } + ) + c.header('Cache-Control', 'no-store') + return c.json({ task }) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.patch('/teams/:id', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = UpdateTeamRequestSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + try { + const team = teams.updateTeamMeta(c.get('namespace'), c.req.param('id'), { + ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}), + ...(parsed.data.status !== undefined ? { status: parsed.data.status } : {}) + }) + c.header('Cache-Control', 'no-store') + return c.json({ team }) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.delete('/teams/:id', (c) => { + try { + teams.deleteTeam(c.get('namespace'), c.req.param('id')) + c.header('Cache-Control', 'no-store') + return c.json({ ok: true }) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.get('/teams/:id/memory', async (c) => { + c.header('Cache-Control', 'no-store') + try { + const files = await teams.listMemoryFiles(c.get('namespace'), c.req.param('id')) + return c.json({ files }) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.get('/teams/:id/memory/file', async (c) => { + c.header('Cache-Control', 'no-store') + const path = c.req.query('path') + if (!path) { + return c.json({ error: 'path is required' }, 400) + } + try { + const file = await teams.readMemoryFile(c.get('namespace'), c.req.param('id'), path) + return c.json(file) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.post('/teams/:id/human-messages', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = TeamHumanMessageRequestSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + try { + const message = await teams.sendHumanMessage(c.get('namespace'), c.req.param('id'), { + text: parsed.data.text, + to: parsed.data.to, + kind: parsed.data.kind + }) + c.header('Cache-Control', 'no-store') + return c.json({ message }, 201) + } catch (error) { + return teamErrorResponse(c, error) + } + }) + + app.post('/teams', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = CreateTeamRequestSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + c.header('Cache-Control', 'no-store') + const team = teams.createTeam(c.get('namespace'), parsed.data) + return c.json({ team }, 201) + }) + + return app +} + +function parsePositiveInt(raw: string | undefined): number | undefined { + if (!raw) return undefined + const value = Number.parseInt(raw, 10) + return Number.isFinite(value) && value > 0 ? value : undefined +} + +function teamErrorResponse(c: Context, error: unknown): Response { + if (error instanceof TeamServiceError) { + switch (error.code) { + case 'not_found': + return c.json({ error: error.message }, 404) + case 'forbidden': + return c.json({ error: error.message }, 403) + case 'budget': + return c.json({ error: error.message }, 429) + case 'invalid': + return c.json({ error: error.message }, 400) + case 'spawn_failed': + return c.json({ error: error.message }, 502) + } + } + const message = error instanceof Error ? error.message : 'Unexpected team error' + return c.json({ error: message }, 502) +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 9f5e9cce..150dd0a5 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -37,8 +37,10 @@ import { createWorkGraphRoutes } from './routes/workGraph' import { createClaudeModelsRoutes } from './routes/claudeModels' import { createPublicStudioRoutes, createStudioRoutes } from './routes/studios' import { createSessionShareRoutes } from './routes/sessionShares' +import { createTeamsRoutes } from './routes/teams' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' +import type { TeamService } from '../teams/teamService' import type { Server as BunServer, ServerWebSocket } from 'bun' import { applyDefaultWsCompression } from './wsCompression' import { acceptsGzip } from './sseCompression' @@ -228,6 +230,7 @@ function createWebApp(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null getVisibilityTracker: () => VisibilityTracker | null + getTeamService?: () => TeamService | null jwtSecret: Uint8Array store: Store vapidPublicKey: string @@ -315,7 +318,11 @@ function createWebApp(options: { app.route('/api', createPermissionsRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createStorageRoutes(configuration.dbPath)) - app.route('/api', createHubSettingsRoutes(configuration.dataDir)) + app.route('/api', createHubSettingsRoutes(configuration.dataDir, () => configuration.teamsEnabled)) + const teamService = options.getTeamService?.() ?? null + if (teamService) { + app.route('/api', createTeamsRoutes(teamService)) + } app.route('/api', createUsageRoutes(options.store)) app.route('/api', createStudioRoutes({ store: options.store, @@ -466,6 +473,7 @@ export async function startWebServer(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null getVisibilityTracker: () => VisibilityTracker | null + getTeamService?: () => TeamService | null jwtSecret: Uint8Array store: Store vapidPublicKey: string @@ -481,6 +489,7 @@ export async function startWebServer(options: { getSyncEngine: options.getSyncEngine, getSseManager: options.getSseManager, getVisibilityTracker: options.getVisibilityTracker, + getTeamService: options.getTeamService, jwtSecret: options.jwtSecret, store: options.store, vapidPublicKey: options.vapidPublicKey, diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 63c864fa..2c760f1f 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -60,7 +60,11 @@ export type CreateSessionResponse = z.infer export const HubSettingsResponseSchema = z.object({ sessionSummaryContract: z.boolean(), /** Show compact AGENT_NOTIFY_SUMMARY in chat (default off / hide). */ - sessionSummaryInChat: z.boolean() + sessionSummaryInChat: z.boolean(), + /** Persisted Agent Team feature gate (applies after a hub restart). */ + teamsEnabled: z.boolean().optional(), + /** Whether the running hub process currently has teams enabled. */ + teamsEnabledActive: z.boolean().optional() }) export type HubSettingsResponse = z.infer @@ -68,10 +72,13 @@ export type HubSettingsResponse = z.infer export const UpdateHubSettingsRequestSchema = z .object({ sessionSummaryContract: z.boolean().optional(), - sessionSummaryInChat: z.boolean().optional() + sessionSummaryInChat: z.boolean().optional(), + teamsEnabled: z.boolean().optional() }) .refine( - (data) => data.sessionSummaryContract !== undefined || data.sessionSummaryInChat !== undefined, + (data) => data.sessionSummaryContract !== undefined + || data.sessionSummaryInChat !== undefined + || data.teamsEnabled !== undefined, { message: 'At least one hub setting field is required' } ) @@ -439,6 +446,76 @@ export const SetSessionPinnedRequestSchema = z.object({ }) export type SetSessionPinnedRequest = z.infer + +export const CreateTeamRequestSchema = z.object({ + name: z.string().min(1).max(200), + leadSessionId: z.string().min(1).optional(), + config: z.record(z.string(), z.unknown()).optional() +}) + +export type CreateTeamRequest = z.infer + +export const TeamSendMessageRequestSchema = z.object({ + fromSessionId: z.string().min(1), + text: z.string().min(1).max(20000), + /** 'all' (default) | 'lead' | member session id or unique prefix. */ + to: z.string().min(1).max(200).optional(), + kind: z.enum(['chat', 'status', 'question', 'task-update', 'decision']).optional(), + /** Team message seq this message replies to (budget chain-depth tracking). */ + inReplyTo: z.number().int().positive().optional() +}) + +export type TeamSendMessageRequest = z.infer + +export const TeamSpawnMemberRequestSchema = z.object({ + fromSessionId: z.string().min(1), + role: z.string().min(1).max(80), + task: z.string().min(1).max(20000).optional(), + agent: AgentFlavorSchema.optional(), + model: z.string().min(1).max(200).optional(), + sessionType: z.enum(['simple', 'worktree']).optional(), + worktreeName: z.string().min(1).max(80).optional(), + /** Run the member with bypassed permission prompts (autonomous work). */ + yolo: z.boolean().optional() +}) + +export type TeamSpawnMemberRequest = z.infer + +export const TeamTaskUpdateRequestSchema = z.object({ + /** Session caller (CLI/MCP). Absent = human web caller. */ + fromSessionId: z.string().min(1).optional(), + status: z.enum(['todo', 'doing', 'done', 'blocked']).optional(), + assigneeSessionId: z.string().min(1).nullable().optional() +}).refine((value) => value.status !== undefined || value.assigneeSessionId !== undefined, { + message: 'status or assigneeSessionId is required' +}) + +export type TeamTaskUpdateRequest = z.infer + +export const CreateTeamTaskRequestSchema = z.object({ + title: z.string().min(1).max(200), + assigneeSessionId: z.string().min(1).nullable().optional() +}) + +export type CreateTeamTaskRequest = z.infer + +export const UpdateTeamRequestSchema = z.object({ + name: z.string().min(1).max(200).optional(), + status: z.enum(['active', 'archived']).optional() +}).refine((value) => value.name !== undefined || value.status !== undefined, { + message: 'name or status is required' +}) + +export type UpdateTeamRequest = z.infer + +export const TeamHumanMessageRequestSchema = z.object({ + text: z.string().min(1).max(20000), + /** 'all' (default) | 'lead' | member session id or unique prefix. */ + to: z.string().min(1).max(200).optional(), + kind: z.enum(['chat', 'status', 'question', 'task-update', 'decision']).optional() +}) + +export type TeamHumanMessageRequest = z.infer export type SessionPinMode = SetSessionPinnedRequest['mode'] /** diff --git a/shared/src/runnerCapabilities.ts b/shared/src/runnerCapabilities.ts index 52d1db36..43b1d31f 100644 --- a/shared/src/runnerCapabilities.ts +++ b/shared/src/runnerCapabilities.ts @@ -11,13 +11,18 @@ import { RPC_METHODS } from './rpcMethods' * socket heartbeat owns the persisted runner_state afterwards — without the * socket-side advertisement, a runner upgraded in place would never get its * new capabilities observed by the hub. + * + * `agentTeam` gates the Agent Team feature (team spawning / peer messaging). + * Old runners omit it; the hub must treat absence as unsupported and keep + * those machines fully usable for regular single sessions. */ import { getBuiltinAgentConfigDescriptors } from './agentConfig' export const RUNNER_CAPABILITIES = { codexSharedRuntime: true as const, piExistingSessionResume: true as const, - agentConfigs: getBuiltinAgentConfigDescriptors() + agentConfigs: getBuiltinAgentConfigDescriptors(), + agentTeam: true as const } export type RunnerCapabilities = typeof RUNNER_CAPABILITIES diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 4bb53bd2..958d94aa 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -502,7 +502,8 @@ export const RunnerStateSchema = z.object({ capabilities: z.object({ codexSharedRuntime: z.literal(true).optional(), piExistingSessionResume: z.literal(true).optional(), - agentConfigs: z.array(AgentConfigDescriptorSchema).optional() + agentConfigs: z.array(AgentConfigDescriptorSchema).optional(), + agentTeam: z.literal(true).optional() }).optional(), shutdownRequestedAt: z.number().optional(), shutdownSource: z.union([z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), z.string()]).optional(), @@ -660,6 +661,22 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ */ resume: z.enum(['ok', 'gap']).optional() }).optional() + }), + SessionEventBaseSchema.extend({ + type: z.literal('team-updated'), + teamId: z.string(), + data: z.unknown().optional() + }), + SessionEventBaseSchema.extend({ + type: z.literal('team-attention'), + teamId: z.string(), + data: z.object({ + teamName: z.string(), + seq: z.number(), + kind: z.string(), + fromRole: z.string(), + text: z.string() + }) }) ]) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0e3a016a..4e6279e6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -43,6 +43,7 @@ import type { SessionShareCreateResponse, SessionShareExchangeResponse } from '@/types/api' +import type { TeamDetail, TeamMemoryFile, TeamMemoryFileContent, TeamMessage, TeamSummary, TeamTask } from '@/types/team' import type { AgentAvailabilityResponse, AgyModelsResponse, @@ -403,6 +404,113 @@ export class ApiClient { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`) } + // ------------------------------------------------------------- Agent Team + + async getTeams(): Promise<{ teams: TeamSummary[] }> { + return await this.request<{ teams: TeamSummary[] }>('/api/teams') + } + + async getTeam(teamId: string): Promise { + return await this.request(`/api/teams/${encodeURIComponent(teamId)}`) + } + + async getTeamMessages( + teamId: string, + options: { afterSeq?: number; limit?: number } = {} + ): Promise<{ messages: TeamMessage[] }> { + const params = new URLSearchParams() + if (options.afterSeq !== undefined) params.set('afterSeq', String(options.afterSeq)) + if (options.limit !== undefined) params.set('limit', String(options.limit)) + const query = params.toString() + return await this.request<{ messages: TeamMessage[] }>( + `/api/teams/${encodeURIComponent(teamId)}/messages${query ? `?${query}` : ''}` + ) + } + + async sendHumanTeamMessage( + teamId: string, + body: { text: string; to?: string; kind?: string } + ): Promise<{ message: TeamMessage }> { + return await this.request<{ message: TeamMessage }>( + `/api/teams/${encodeURIComponent(teamId)}/human-messages`, + { method: 'POST', body: JSON.stringify(body) } + ) + } + + async getTeamMemory(teamId: string): Promise<{ files: TeamMemoryFile[] }> { + return await this.request<{ files: TeamMemoryFile[] }>( + `/api/teams/${encodeURIComponent(teamId)}/memory` + ) + } + + async getTeamMemoryFile(teamId: string, path: string): Promise { + const params = new URLSearchParams({ path }) + return await this.request( + `/api/teams/${encodeURIComponent(teamId)}/memory/file?${params.toString()}` + ) + } + + async createTeamTask( + teamId: string, + body: { title: string; assigneeSessionId?: string | null } + ): Promise<{ task: TeamTask }> { + return await this.request<{ task: TeamTask }>( + `/api/teams/${encodeURIComponent(teamId)}/tasks`, + { method: 'POST', body: JSON.stringify(body) } + ) + } + + async updateTeamTask( + teamId: string, + taskId: string, + body: { status?: string; assigneeSessionId?: string | null } + ): Promise<{ task: TeamTask }> { + return await this.request<{ task: TeamTask }>( + `/api/teams/${encodeURIComponent(teamId)}/tasks/${encodeURIComponent(taskId)}`, + { method: 'PATCH', body: JSON.stringify(body) } + ) + } + + async updateTeam( + teamId: string, + body: { name?: string; status?: 'active' | 'archived' } + ): Promise<{ team: TeamSummary }> { + return await this.request<{ team: TeamSummary }>( + `/api/teams/${encodeURIComponent(teamId)}`, + { method: 'PATCH', body: JSON.stringify(body) } + ) + } + + async deleteTeam(teamId: string): Promise { + await this.request(`/api/teams/${encodeURIComponent(teamId)}`, { method: 'DELETE' }) + } + + async createTeam(body: { name: string; leadSessionId?: string }): Promise<{ team: TeamSummary }> { + return await this.request<{ team: TeamSummary }>('/api/teams', { + method: 'POST', + body: JSON.stringify(body) + }) + } + + async spawnTeamMember( + teamId: string, + body: { + fromSessionId: string + role: string + task?: string + agent?: string + model?: string + sessionType?: 'simple' | 'worktree' + worktreeName?: string + yolo?: boolean + } + ): Promise<{ teamId: string; sessionId: string; role: string; taskId: string | null }> { + return await this.request( + `/api/teams/${encodeURIComponent(teamId)}/spawn`, + { method: 'POST', body: JSON.stringify(body) } + ) + } + async createSessionShare(sessionId: string): Promise { return await this.request('/api/session-shares', { method: 'POST', body: JSON.stringify({ sessionId }) }) } diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index af6b4e47..bb5afac8 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -1251,6 +1251,8 @@ export function SessionList(props: { selectedSessionId?: string | null /** Compact directory/session index used while the detail sidebar is collapsed. */ compact?: boolean + /** Optional content rendered at the top of the scroll area (e.g. team list). */ + topSection?: React.ReactNode }) { const { t } = useTranslation() const { @@ -2083,6 +2085,7 @@ export function SessionList(props: { ) : null}
+ {props.topSection} {props.sessions.length === 0 && !props.isLoading ? ( void + onSpawned: (role: string) => void +}) { + const { api } = useAppContext() + const { t } = useTranslation() + const queryClient = useQueryClient() + const [role, setRole] = useState('') + const [task, setTask] = useState('') + const [yolo, setYolo] = useState(false) + const [agent, setAgent] = useState('') + const [worktree, setWorktree] = useState(false) + const [error, setError] = useState(null) + + const spawn = useMutation({ + mutationFn: async () => { + if (!api) throw new Error('API unavailable') + if (!props.leadSessionId) throw new Error(t('team.add.noLead')) + return await api.spawnTeamMember(props.teamId, { + fromSessionId: props.leadSessionId, + role: role.trim(), + task: task.trim() || undefined, + yolo: yolo || undefined, + agent: agent || undefined, + sessionType: worktree ? 'worktree' : undefined, + }) + }, + onSuccess: async (data) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.team(props.teamId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.teamMessages(props.teamId) }), + ]) + setRole('') + setTask('') + setYolo(false) + setAgent('') + setWorktree(false) + props.onSpawned(data.role) + }, + onError: (mutationError) => { + setError(mutationError instanceof Error ? mutationError.message : String(mutationError)) + }, + }) + + const close = () => { + setError(null) + props.onClose() + } + + const canSubmit = Boolean(props.leadSessionId) && role.trim().length > 0 && !spawn.isPending + + return ( + + + + + } + > +
+ {!props.leadSessionId ? ( +
+ {t('team.add.noLead')} +
+ ) : null} + +