From 75afabb974d4c89068a544fb0e05a54f8b6fc52e Mon Sep 17 00:00:00 2001 From: tfq <45652027+0x7551@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:54:56 +0800 Subject: [PATCH] feat: Agent Teams improvements - SSE fix, team state parsing, and permission handling - Replace Hono streamSSE with push-based ReadableStream to fix Bun SSE buffering delay - Enhance team state parsing: teammate messages, idle notifications, shutdown responses - Extract effective team name from TeamCreate results, normalize task IDs - Add --dangerously-skip-permissions for local teammate mode with deny rules for dangerous commands - Rewrite TeamPanel component with expandable member activity details - Add TeamCreate tool card rendering - Add comprehensive team parsing tests --- cli/src/api/apiMachine.ts | 2 +- cli/src/api/types.ts | 9 +- cli/src/claude/claudeLocal.ts | 6 + cli/src/claude/claudeRemote.ts | 5 +- cli/src/claude/sdk/query.ts | 2 +- cli/src/claude/utils/permissionHandler.ts | 7 +- .../common/hooks/generateHookSettings.ts | 22 + cli/src/runner/run.ts | 152 +---- .../socket/handlers/cli/sessionHandlers.ts | 19 +- hub/src/sync/sessionCache.ts | 13 + hub/src/sync/syncEngine.ts | 4 + hub/src/sync/teams.test.ts | 384 ++++++++++++- hub/src/sync/teams.ts | 344 ++++++++++- hub/src/web/routes/events.ts | 110 ++-- hub/src/web/routes/permissions.ts | 124 +++- shared/src/schemas.ts | 22 +- shared/src/types.ts | 1 + web/src/chat/reducerTimeline.ts | 35 +- .../AssistantChat/HappyComposer.tsx | 4 + .../components/AssistantChat/StatusBar.tsx | 19 +- web/src/components/SessionChat.tsx | 30 +- web/src/components/TeamPanel.tsx | 538 ++++++++++++++++-- web/src/components/ToolCard/knownTools.tsx | 27 +- .../ToolCard/views/TeamCreateView.tsx | 104 ++++ web/src/components/ToolCard/views/_all.tsx | 3 + .../components/ToolCard/views/_results.tsx | 2 + 26 files changed, 1683 insertions(+), 305 deletions(-) create mode 100644 web/src/components/ToolCard/views/TeamCreateView.tsx diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 8c6ea7a07..e38d08893 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -127,7 +127,7 @@ export class ApiMachineClient { case 'requestToApproveDirectoryCreation': return { type: 'requestToApproveDirectoryCreation', directory: result.directory } case 'error': - return { type: 'error', errorMessage: result.errorMessage } + throw new Error(result.errorMessage) } }) diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index b5afb718c..8bfa0b79e 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -45,14 +45,7 @@ export const RunnerStateSchema = z.object({ httpPort: z.number().optional(), startedAt: z.number().optional(), shutdownRequestedAt: z.number().optional(), - shutdownSource: z.union([z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), z.string()]).optional(), - lastSpawnError: z.object({ - message: z.string(), - pid: z.number().optional(), - exitCode: z.number().nullable().optional(), - signal: z.string().nullable().optional(), - at: z.number() - }).nullable().optional() + shutdownSource: z.union([z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), z.string()]).optional() }) export type RunnerState = z.infer diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 5e3e27cfe..488f05c00 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -67,6 +67,12 @@ export async function claudeLocal(opts: { args.push(...opts.claudeArgs); } + // Bypass Claude's built-in terminal permission prompts. + // In HAPI, permissions are managed by the web UI via hooks/settings, + // not by Claude's interactive terminal prompts. Without this, subagent + // and teammate tool calls block waiting for terminal input that never comes. + args.push('--dangerously-skip-permissions'); + // Add hook settings for session tracking args.push('--settings', opts.hookSettingsPath); logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`); diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 3d534e3b0..dfcbb4813 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -114,7 +114,10 @@ export async function claudeRemote(opts: { cwd: opts.path, resume: startFrom ?? undefined, mcpServers: opts.mcpServers, - permissionMode: initial.mode.permissionMode, + // Use 'bypassPermissions' so the SDK auto-approves teammate/sub-agent + // permissions internally. Main agent permissions are still gated by + // canCallTool (which ignores this mode and uses its own approval flow). + permissionMode: 'bypassPermissions', model: initial.mode.model, fallbackModel: initial.mode.fallbackModel, customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + '\n\n' + systemPrompt : undefined, diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 41af155cd..f784a14bc 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -236,7 +236,7 @@ export class Query implements AsyncIterableIterator { signal }) } - + throw new Error('Unsupported control request subtype: ' + request.request.subtype) } diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index 444bf7af0..66a745c84 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -306,7 +306,12 @@ export class PermissionHandler extends BasePermissionHandler }; } } return this.handlePermissionRequest(toolCallId, toolName, input, options.signal); diff --git a/cli/src/modules/common/hooks/generateHookSettings.ts b/cli/src/modules/common/hooks/generateHookSettings.ts index b61280131..64c410f03 100644 --- a/cli/src/modules/common/hooks/generateHookSettings.ts +++ b/cli/src/modules/common/hooks/generateHookSettings.ts @@ -19,6 +19,9 @@ type HookSettings = { hooks: { SessionStart: HookCommandConfig[]; }; + permissions?: { + deny?: string[]; + }; }; export type HookSettingsOptions = { @@ -65,6 +68,25 @@ function buildHookSettings(command: string, hooksEnabled?: boolean): HookSetting }; } + // Deny dangerous Bash patterns as a safety net. + // Even with --dangerously-skip-permissions, deny rules are still enforced. + settings.permissions = { + deny: [ + 'Bash(rm -rf:*)', + 'Bash(rm -r /:*)', + 'Bash(sudo rm:*)', + 'Bash(sudo chmod:*)', + 'Bash(sudo chown:*)', + 'Bash(mkfs:*)', + 'Bash(dd if=:*)', + 'Bash(git push --force:*)', + 'Bash(git push -f:*)', + 'Bash(git reset --hard:*)', + 'Bash(> /dev/:*)', + 'Bash(chmod 777:*)', + ] + }; + return settings; } diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 058d27430..3ae5ac441 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -127,20 +127,6 @@ export async function startRunner(): Promise { // Session spawning awaiter system const pidToAwaiter = new Map void>(); - const pidToErrorAwaiter = new Map void>(); - type SpawnFailureDetails = { - message: string - pid?: number - exitCode?: number | null - signal?: NodeJS.Signals | null - }; - let reportSpawnOutcomeToHub: ((outcome: { type: 'success' } | { type: 'error'; details: SpawnFailureDetails }) => void) | null = null; - const formatSpawnError = (error: unknown): string => { - if (error instanceof Error) { - return error.message; - } - return String(error); - }; // Helper functions const getCurrentChildren = () => Array.from(pidToTrackedSession.values()); @@ -171,7 +157,6 @@ export async function startRunner(): Promise { const awaiter = pidToAwaiter.get(pid); if (awaiter) { pidToAwaiter.delete(pid); - pidToErrorAwaiter.delete(pid); awaiter(existingSession); logger.debug(`[RUNNER RUN] Resolved session awaiter for PID ${pid}`); } @@ -398,66 +383,17 @@ export async function startRunner(): Promise { stderrTail = appendTail(stderrTail, data); }); - let spawnErrorBeforePidCheck: Error | null = null; - const captureSpawnErrorBeforePidCheck = (error: Error) => { - spawnErrorBeforePidCheck = error; - }; - happyProcess.once('error', captureSpawnErrorBeforePidCheck); - if (!happyProcess.pid) { - // Allow the async 'error' event to fire before we read it - await new Promise((resolve) => setImmediate(resolve)); - const details = [`cwd=${spawnDirectory}`]; - if (spawnErrorBeforePidCheck) { - details.push(formatSpawnError(spawnErrorBeforePidCheck)); - } - const errorMessage = `Failed to spawn HAPI process - no PID returned (${details.join('; ')})`; - logger.debug('[RUNNER RUN] Failed to spawn process - no PID returned', spawnErrorBeforePidCheck ?? null); - reportSpawnOutcomeToHub?.({ - type: 'error', - details: { - message: errorMessage - } - }); + logger.debug('[RUNNER RUN] Failed to spawn process - no PID returned'); await maybeCleanupWorktree('no-pid'); return { type: 'error', - errorMessage + errorMessage: 'Failed to spawn HAPI process - no PID returned' }; } - happyProcess.removeListener('error', captureSpawnErrorBeforePidCheck); const pid = happyProcess.pid; logger.debug(`[RUNNER RUN] Spawned process with PID ${pid}`); - let observedExitCode: number | null = null; - let observedExitSignal: NodeJS.Signals | null = null; - const buildWebhookFailureMessage = (reason: 'timeout' | 'exit-before-webhook' | 'process-error-before-webhook'): string => { - let message = ''; - if (reason === 'exit-before-webhook') { - message = `Session process exited before webhook for PID ${pid}`; - } else if (reason === 'process-error-before-webhook') { - message = `Session process error before webhook for PID ${pid}`; - } else { - message = `Session webhook timeout for PID ${pid}`; - } - - if (observedExitCode !== null || observedExitSignal) { - if (observedExitCode !== null) { - message += ` (exit code ${observedExitCode})`; - } else { - message += ` (signal ${observedExitSignal})`; - } - } - - const trimmedTail = stderrTail.trim(); - if (trimmedTail) { - const compactTail = trimmedTail.replace(/\s+/g, ' '); - const tailForMessage = compactTail.length > 800 ? compactTail.slice(-800) : compactTail; - message += `. stderr: ${tailForMessage}`; - } - - return message; - }; const trackedSession: TrackedSession = { startedBy: 'runner', @@ -470,29 +406,15 @@ export async function startRunner(): Promise { pidToTrackedSession.set(pid, trackedSession); happyProcess.on('exit', (code, signal) => { - observedExitCode = typeof code === 'number' ? code : null; - observedExitSignal = signal ?? null; logger.debug(`[RUNNER RUN] Child PID ${pid} exited with code ${code}, signal ${signal}`); if (code !== 0 || signal) { logStderrTail(); } - const errorAwaiter = pidToErrorAwaiter.get(pid); - if (errorAwaiter) { - pidToErrorAwaiter.delete(pid); - pidToAwaiter.delete(pid); - errorAwaiter(buildWebhookFailureMessage('exit-before-webhook')); - } onChildExited(pid); }); happyProcess.on('error', (error) => { logger.debug(`[RUNNER RUN] Child process error:`, error); - const errorAwaiter = pidToErrorAwaiter.get(pid); - if (errorAwaiter) { - pidToErrorAwaiter.delete(pid); - pidToAwaiter.delete(pid); - errorAwaiter(buildWebhookFailureMessage('process-error-before-webhook')); - } onChildExited(pid); }); @@ -503,12 +425,11 @@ export async function startRunner(): Promise { // Set timeout for webhook const timeout = setTimeout(() => { pidToAwaiter.delete(pid); - pidToErrorAwaiter.delete(pid); logger.debug(`[RUNNER RUN] Session webhook timeout for PID ${pid}`); logStderrTail(); resolve({ type: 'error', - errorMessage: buildWebhookFailureMessage('timeout') + errorMessage: `Session webhook timeout for PID ${pid}` }); // 15 second timeout - I have seen timeouts on 10 seconds // even though session was still created successfully in ~2 more seconds @@ -517,46 +438,21 @@ export async function startRunner(): Promise { // Register awaiter pidToAwaiter.set(pid, (completedSession) => { clearTimeout(timeout); - pidToErrorAwaiter.delete(pid); logger.debug(`[RUNNER RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); resolve({ type: 'success', sessionId: completedSession.happySessionId! }); }); - pidToErrorAwaiter.set(pid, (errorMessage) => { - clearTimeout(timeout); - resolve({ - type: 'error', - errorMessage - }); - }); }); - if (spawnResult.type === 'error') { - reportSpawnOutcomeToHub?.({ - type: 'error', - details: { - message: spawnResult.errorMessage, - pid, - exitCode: observedExitCode, - signal: observedExitSignal - } - }); + if (spawnResult.type !== 'success') { await maybeCleanupWorktree('spawn-error'); - } else { - reportSpawnOutcomeToHub?.({ type: 'success' }); } return spawnResult; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.debug('[RUNNER RUN] Failed to spawn session:', error); await maybeCleanupWorktree('exception'); - reportSpawnOutcomeToHub?.({ - type: 'error', - details: { - message: `Failed to spawn session: ${errorMessage}` - } - }); return { type: 'error', errorMessage: `Failed to spawn session: ${errorMessage}` @@ -604,8 +500,6 @@ export async function startRunner(): Promise { const onChildExited = (pid: number) => { logger.debug(`[RUNNER RUN] Removing exited process PID ${pid} from tracking`); pidToTrackedSession.delete(pid); - pidToAwaiter.delete(pid); - pidToErrorAwaiter.delete(pid); }; // Start control server @@ -675,44 +569,6 @@ export async function startRunner(): Promise { // Connect to server apiMachine.connect(); - reportSpawnOutcomeToHub = (outcome) => { - void apiMachine.updateRunnerState((state: RunnerState | null) => { - const baseState: RunnerState = state - ? { ...state } - : { status: 'running' }; - - if (typeof baseState.pid !== 'number') { - baseState.pid = process.pid; - } - if (typeof baseState.httpPort !== 'number') { - baseState.httpPort = controlPort; - } - if (typeof baseState.startedAt !== 'number') { - baseState.startedAt = Date.now(); - } - - if (outcome.type === 'success') { - return { - ...baseState, - lastSpawnError: null - }; - } - - return { - ...baseState, - lastSpawnError: { - message: outcome.details.message, - pid: outcome.details.pid, - exitCode: outcome.details.exitCode ?? null, - signal: outcome.details.signal ?? null, - at: Date.now() - } - }; - }).catch((error) => { - logger.debug('[RUNNER RUN] Failed to update runner state with spawn outcome', error); - }); - }; - // Every 60 seconds: // 1. Prune stale sessions // 2. Check if runner needs update diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 89c31c86c..e3e91ff74 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -1,7 +1,7 @@ import type { ClientToServerEvents } from '@hapi/protocol' import { z } from 'zod' import { randomUUID } from 'node:crypto' -import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, PermissionMode, TeamState } from '@hapi/protocol/types' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' @@ -99,13 +99,23 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session const teamDelta = extractTeamStateFromMessageContent(content) if (teamDelta) { - const existingSession = store.sessions.getSession(sid) + const existingSession = store.sessions.getSessionByNamespace(sid, session.namespace) const existingTeamState = existingSession?.teamState as import('@hapi/protocol/types').TeamState | null | undefined const newTeamState = applyTeamStateDelta(existingTeamState ?? null, teamDelta) - const updated = store.sessions.setSessionTeamState(sid, newTeamState, msg.createdAt, session.namespace) + // Guard against accidental team-state wipe: + // if we only got an incremental update but no existing team state, skip persistence. + const shouldPersist = !(teamDelta._action === 'update' && !existingTeamState && newTeamState === null) + const updated = shouldPersist + ? store.sessions.setSessionTeamState(sid, newTeamState, msg.createdAt, session.namespace) + : false if (updated) { onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } }) } + + // Note: teammate permission_request messages are part of Claude's internal + // team protocol. They cannot be approved via RPC — the teammate resolves + // permissions through its own SendMessage-based approval flow with the + // team lead agent. We no longer attempt auto-approve here. } const update = { @@ -228,6 +238,9 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } socket.to(`session:${sid}`).emit('update', update) onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } }) + + // Note: teammate permissions are resolved internally by the team lead + // agent via SendMessage. We no longer sync or auto-approve them. } } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 4a8ab0c88..a82bacb0e 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -136,6 +136,19 @@ export class SessionCache { return session } + updateTeamState(sessionId: string, teamState: unknown, namespace: string): void { + const now = Date.now() + const updated = this.store.sessions.setSessionTeamState(sessionId, teamState, now, namespace) + if (updated) { + const session = this.sessions.get(sessionId) + if (session) { + const parsed = TeamStateSchema.safeParse(teamState) + session.teamState = parsed.success ? parsed.data : undefined + this.publisher.emit({ type: 'session-updated', sessionId, data: session }) + } + } + } + reloadAll(): void { const sessions = this.store.sessions.getSessions() for (const session of sessions) { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 063e441a9..a9ed318be 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -257,6 +257,10 @@ export class SyncEngine { await this.rpcGateway.denyPermission(sessionId, requestId, decision) } + updateSessionTeamState(sessionId: string, teamState: unknown, namespace: string): void { + this.sessionCache.updateTeamState(sessionId, teamState, namespace) + } + async abortSession(sessionId: string): Promise { await this.rpcGateway.abortSession(sessionId) } diff --git a/hub/src/sync/teams.test.ts b/hub/src/sync/teams.test.ts index 968c6b10f..b979d2bb3 100644 --- a/hub/src/sync/teams.test.ts +++ b/hub/src/sync/teams.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test' -import { applyTeamStateDelta } from './teams' +import { applyTeamStateDelta, extractTeamStateFromMessageContent } from './teams' import type { TeamState, TeamTask } from '@hapi/protocol/types' const baseTeamState: TeamState = { @@ -15,6 +15,27 @@ function getTasks(result: TeamState | null | undefined): TeamTask[] { return result!.tasks ?? [] } +// Helper to create a message content envelope with tool_use blocks +function makeToolCallMessage(tools: Array<{ name: string; input: Record }>) { + return { + role: 'assistant', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: tools.map((t, i) => ({ + type: 'tool_use', + id: `tool_${i}`, + name: t.name, + input: t.input + })) + } + } + } + } +} + describe('applyTeamStateDelta - orphan TaskUpdate', () => { test('should skip inserting task without title (orphan TaskUpdate)', () => { const result = applyTeamStateDelta(baseTeamState, { @@ -70,4 +91,365 @@ describe('applyTeamStateDelta - orphan TaskUpdate', () => { expect(tasks).toHaveLength(1) expect(tasks[0]).toMatchObject({ id: 'task-1', status: 'in_progress' }) }) + + test('should match agent task ids with and without @team suffix', () => { + const stateWithTask: TeamState = { + ...baseTeamState, + tasks: [{ id: 'agent:coder', title: 'Coder task', status: 'in_progress' }] + } + + const result = applyTeamStateDelta(stateWithTask, { + tasks: [{ id: 'agent:coder@quick-check', status: 'completed' } as any], + updatedAt: 2000 + }) + + const tasks = getTasks(result) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ id: 'agent:coder', status: 'completed' }) + }) + + test('should canonicalize new agent task ids by dropping @team suffix', () => { + const result = applyTeamStateDelta(baseTeamState, { + tasks: [{ id: 'agent:researcher@quick-check', title: 'Research task', status: 'in_progress' }], + updatedAt: 2000 + }) + + const tasks = getTasks(result) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ id: 'agent:researcher', title: 'Research task' }) + }) +}) + +describe('extractTeamStateFromMessageContent - Agent tool', () => { + test('should extract Agent tool as team member spawn', () => { + const msg = makeToolCallMessage([{ + name: 'Agent', + input: { + name: 'researcher', + description: 'Research API docs', + prompt: 'Find all API endpoints', + subagent_type: 'Explore', + team_name: 'my-team' + } + }]) + + const delta = extractTeamStateFromMessageContent(msg) + expect(delta).toBeTruthy() + expect(delta!.members).toHaveLength(1) + expect(delta!.members![0]).toMatchObject({ + name: 'researcher', + agentType: 'Explore', + status: 'active' + }) + expect(delta!.tasks).toHaveLength(1) + expect(delta!.tasks![0]).toMatchObject({ + id: 'agent:researcher', + title: 'Research API docs', + status: 'in_progress', + owner: 'researcher' + }) + }) + + test('should extract Agent tool with background and worktree flags', () => { + const msg = makeToolCallMessage([{ + name: 'Agent', + input: { + name: 'builder', + description: 'Build the project', + team_name: 'my-team', + run_in_background: true, + isolation: 'worktree' + } + }]) + + const delta = extractTeamStateFromMessageContent(msg) + expect(delta).toBeTruthy() + expect(delta!.members![0]).toMatchObject({ + name: 'builder', + status: 'active', + runInBackground: true, + isolation: 'worktree' + }) + }) + + test('should NOT extract Agent tool without team_name (standalone subagent)', () => { + const msg = makeToolCallMessage([{ + name: 'Agent', + input: { + name: 'worker', + description: 'Do work' + } + }]) + + const delta = extractTeamStateFromMessageContent(msg) + expect(delta).toBeNull() + }) + + test('should still extract Task tool with team_name as legacy spawn', () => { + const msg = makeToolCallMessage([{ + name: 'Task', + input: { + name: 'legacy-agent', + team_name: 'my-team', + description: 'Legacy task' + } + }]) + + const delta = extractTeamStateFromMessageContent(msg) + expect(delta).toBeTruthy() + expect(delta!.members).toHaveLength(1) + expect(delta!.members![0].name).toBe('legacy-agent') + }) + + test('should NOT extract Task tool without team_name (regular task)', () => { + const msg = makeToolCallMessage([{ + name: 'Task', + input: { + description: 'Regular non-team task' + } + }]) + + const delta = extractTeamStateFromMessageContent(msg) + expect(delta).toBeNull() + }) + + test('should extract multiple tools from same message', () => { + const msg = makeToolCallMessage([ + { + name: 'TeamCreate', + input: { team_name: 'project-x', description: 'Project team' } + }, + { + name: 'Agent', + input: { name: 'dev-1', description: 'Frontend work', subagent_type: 'general-purpose', team_name: 'project-x' } + } + ]) + + const delta = extractTeamStateFromMessageContent(msg) + expect(delta).toBeTruthy() + expect(delta!.teamName).toBe('project-x') + expect(delta!.members).toHaveLength(1) + expect(delta!.members![0].name).toBe('dev-1') + }) + + test('should extract SendMessage with shutdown_request', () => { + const msg = makeToolCallMessage([{ + name: 'SendMessage', + input: { + type: 'shutdown_request', + recipient: 'researcher', + summary: 'Work is done' + } + }]) + + const delta = extractTeamStateFromMessageContent(msg) + expect(delta).toBeTruthy() + expect(delta!.messages).toHaveLength(1) + expect(delta!.messages![0].type).toBe('shutdown_request') + expect(delta!.members).toHaveLength(1) + expect(delta!.members![0]).toMatchObject({ + name: 'researcher', + status: 'shutdown' + }) + }) +}) + +describe('extractTeamStateFromMessageContent - teammate messages', () => { + const permissionRequestJson = JSON.stringify({ + type: 'permission_request', + request_id: 'perm-123', + agent_id: 'todo-scanner', + tool_name: 'Bash', + tool_use_id: 'toolu_abc', + description: 'Run tests', + input: { command: 'npm test' } + }) + const teammateXml = `\n${permissionRequestJson}\n` + + // permission_request messages are skipped (resolved internally by team lead agent) + test('should skip permission_request - returns null delta', () => { + const content = { + role: 'user', + content: { type: 'text', text: teammateXml }, + meta: { sentFrom: 'cli' } + } + const delta = extractTeamStateFromMessageContent(content) + expect(delta).toBeNull() + }) + + test('should skip permission_request from agent-wrapped format', () => { + const content = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { content: teammateXml }, + isSidechain: true + } + } + } + const delta = extractTeamStateFromMessageContent(content) + expect(delta).toBeNull() + }) + + // Format 6: idle_notification + test('should parse idle_notification from teammate message', () => { + const idleJson = JSON.stringify({ type: 'idle_notification', agent_id: 'worker' }) + const idleXml = `\n${idleJson}\n` + const content = { + role: 'user', + content: { type: 'text', text: idleXml } + } + const delta = extractTeamStateFromMessageContent(content) + expect(delta).toBeTruthy() + expect(delta!.members).toHaveLength(1) + expect(delta!.members![0]).toMatchObject({ + name: 'worker', + status: 'idle' + }) + }) + + test('should parse multiple teammate-message tags in a single payload', () => { + const payload = [ + '', + 'started scanning team files', + '', + '', + '', + '{"type":"idle_notification","from":"coder"}', + '' + ].join('\n') + + const content = { + role: 'user', + content: payload + } + + const delta = extractTeamStateFromMessageContent(content) + expect(delta).toBeTruthy() + expect(delta!.members).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'researcher', + lastOutput: 'started scanning team files' + }), + expect.objectContaining({ + name: 'coder', + status: 'idle' + }) + ])) + expect(delta!.tasks).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'agent:coder', + status: 'completed' + }) + ])) + expect(delta!.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + from: 'researcher', + to: 'team-lead', + summary: 'started scanning team files' + }), + expect.objectContaining({ + from: 'coder', + to: 'team-lead', + summary: 'idle' + }) + ])) + }) +}) + +describe('extractTeamStateFromMessageContent - TeamCreate tool result', () => { + test('should extract effective team_name from TeamCreate tool_result payload', () => { + const content = { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + content: [{ + type: 'tool_result', + tool_use_id: 'toolu_team_create', + content: { + team_name: 'elegant-orbiting-corbato', + team_file_path: '/tmp/config.json', + lead_agent_id: 'team-lead@elegant-orbiting-corbato' + } + }] + }, + isSidechain: true + } + } + } + + const delta = extractTeamStateFromMessageContent(content) + expect(delta).toBeTruthy() + expect(delta!._action).toBe('update') + expect(delta!.teamName).toBe('elegant-orbiting-corbato') + }) +}) + +describe('applyTeamStateDelta - member properties', () => { + test('should preserve new member fields (description, isolation, runInBackground)', () => { + const result = applyTeamStateDelta(baseTeamState, { + _action: 'update', + members: [{ + name: 'worker', + agentType: 'Explore', + status: 'active', + description: 'Search codebase', + isolation: 'worktree', + runInBackground: true + }], + updatedAt: 2000 + }) + + expect(result).toBeTruthy() + const members = result!.members ?? [] + const worker = members.find(m => m.name === 'worker') + expect(worker).toBeTruthy() + expect(worker!.description).toBe('Search codebase') + expect(worker!.isolation).toBe('worktree') + expect(worker!.runInBackground).toBe(true) + }) + + test('should merge member updates preserving existing fields', () => { + const stateWithMember: TeamState = { + ...baseTeamState, + members: [{ + name: 'worker', + agentType: 'Explore', + status: 'active', + description: 'Search codebase', + isolation: 'worktree' + }] + } + + const result = applyTeamStateDelta(stateWithMember, { + _action: 'update', + members: [{ name: 'worker', status: 'completed' }], + updatedAt: 2000 + }) + + const worker = result!.members!.find(m => m.name === 'worker') + expect(worker!.status).toBe('completed') + expect(worker!.description).toBe('Search codebase') + expect(worker!.isolation).toBe('worktree') + }) +}) + +describe('applyTeamStateDelta - metadata updates', () => { + test('should update teamName/description on update delta', () => { + const result = applyTeamStateDelta(baseTeamState, { + _action: 'update', + teamName: 'effective-team-name', + description: 'Effective team description', + updatedAt: Date.now() + }) + + expect(result).toBeTruthy() + expect(result!.teamName).toBe('effective-team-name') + expect(result!.description).toBe('Effective team description') + }) }) diff --git a/hub/src/sync/teams.ts b/hub/src/sync/teams.ts index 941f7d13a..223761773 100644 --- a/hub/src/sync/teams.ts +++ b/hub/src/sync/teams.ts @@ -2,7 +2,24 @@ import { isObject } from '@hapi/protocol' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { TeamState } from '@hapi/protocol/types' -type TeamStateDelta = Partial & { _action?: 'create' | 'delete' | 'update' } +type TeamStateDeltaTask = { + id: string + title?: string + description?: string + status?: 'pending' | 'in_progress' | 'completed' | 'blocked' + owner?: string +} + +type TeamStateDelta = { + _action?: 'create' | 'delete' | 'update' + teamName?: string + description?: string + members?: TeamState['members'] + tasks?: TeamStateDeltaTask[] + messages?: TeamState['messages'] + pendingPermissions?: TeamState['pendingPermissions'] + updatedAt?: number +} function extractToolBlocks(content: Record): Array<{ name: string; input: Record }> { const blocks: Array<{ name: string; input: Record }> = [] @@ -61,17 +78,32 @@ function processTeamDelete(): TeamStateDelta { return { _action: 'delete' } } -function processTaskToolWithTeam(input: Record): TeamStateDelta | null { - const teamName = typeof input.team_name === 'string' ? input.team_name : null +/** + * Process Agent tool call - the primary tool for spawning teammates in Claude Code. + * Also handles the legacy Task tool with team_name parameter. + */ +function processAgentSpawn(input: Record): TeamStateDelta | null { const name = typeof input.name === 'string' ? input.name : null - if (!teamName || !name) return null + const description = typeof input.description === 'string' ? input.description : null + + // Agent tool: always creates a member. team_name is optional (uses current team context). + // Task tool: requires team_name to be treated as a team spawn. + if (!name) return null const agentType = typeof input.subagent_type === 'string' ? input.subagent_type : undefined - const description = typeof input.description === 'string' ? input.description : null + const runInBackground = input.run_in_background === true ? true : undefined + const isolation = input.isolation === 'worktree' ? 'worktree' as const : undefined const delta: TeamStateDelta = { _action: 'update', - members: [{ name, agentType, status: 'active' }], + members: [{ + name, + agentType, + status: 'active', + runInBackground, + isolation, + description: description ?? undefined + }], updatedAt: Date.now() } @@ -125,7 +157,7 @@ function processTaskUpdate(input: Record): TeamStateDelta | nul return { _action: 'update', - tasks: [task as { id: string; title: string; status?: 'pending' | 'in_progress' | 'completed' | 'blocked'; owner?: string }], + tasks: [task as TeamStateDeltaTask], updatedAt: Date.now() } } @@ -154,7 +186,7 @@ function processSendMessage(input: Record): TeamStateDelta | nu updatedAt: Date.now() } - // If shutdown_request with approve=true, mark member as shutdown + // If shutdown_request, mark member as shutdown if (msgType === 'shutdown_request' && recipient) { delta.members = [{ name: recipient, status: 'shutdown' }] } @@ -162,10 +194,256 @@ function processSendMessage(input: Record): TeamStateDelta | nu return delta } +function findTeammateMessageInContent(value: unknown): string | null { + if (typeof value === 'string') { + return value.includes('...' | { type: 'text', text: '...' } | [{ type: 'text', text: '...' }] } + if (record.role === 'user') { + if (typeof record.content === 'string') return record.content + if (isObject(record.content) && record.content.type === 'text' && typeof record.content.text === 'string') { + return record.content.text + } + const found = findTeammateMessageInContent(record.content) + if (found) { + return found + } + } + + // Agent-wrapped (isSidechain/isMeta): { role: 'agent', content: { type: 'output', data: { type: 'user', message: { content: '...' } } } } + if (record.role === 'agent' && isObject(record.content) && record.content.type === 'output') { + const data = isObject(record.content.data) ? record.content.data : null + if (data && data.type === 'user' && isObject(data.message)) { + if (typeof data.message.content === 'string') return data.message.content + const found = findTeammateMessageInContent(data.message.content) + if (found) { + return found + } + } + } + + return null +} + +function extractTeammateMessage(record: { role: string; content: unknown }): TeamStateDelta | null { + const text = extractTeammateMessageText(record) + if (!text || !text.includes(']*teammate_id="([^"]+)"[^>]*>([\s\S]*?)<\/teammate-message>/g + let result: TeamStateDelta | null = null + const now = Date.now() + + for (const match of text.matchAll(tagRegex)) { + const memberId = match[1] + const body = match[2].trim() + if (!memberId || !body) continue + + // Try to parse as JSON (structured protocol messages) + let parsed: Record | null = null + try { + parsed = JSON.parse(body) as Record + } catch { + // Not JSON — plain text output from teammate + } + + if (parsed) { + // permission_request — these are informational only. + // Teammate permissions are resolved internally by the team lead agent + // via SendMessage, not through external approval. Skip storing them + // in pendingPermissions to avoid showing unapprovable UI cards. + if (parsed.type === 'permission_request') { + continue + } + + // idle_notification — update member status and mark agent task as completed + if (parsed.type === 'idle_notification') { + const delta: TeamStateDelta = { + _action: 'update', + members: [{ + name: memberId, + status: 'idle' + }], + tasks: [{ + id: `agent:${memberId}`, + status: 'completed' + }], + messages: [{ + from: memberId, + to: 'team-lead', + summary: 'idle', + type: 'message', + timestamp: now + }], + updatedAt: now + } + result = result ? mergeDelta(result, delta) : delta + continue + } + + if (parsed.type === 'shutdown_approved') { + const delta: TeamStateDelta = { + _action: 'update', + members: [{ + name: memberId, + status: 'shutdown' + }], + messages: [{ + from: memberId, + to: 'team-lead', + summary: 'shutdown approved', + type: 'shutdown_response', + timestamp: now + }], + updatedAt: now + } + result = result ? mergeDelta(result, delta) : delta + continue + } + + // Other structured messages — store summary as lastOutput + const summary = typeof parsed.summary === 'string' ? parsed.summary + : typeof parsed.content === 'string' ? parsed.content + : null + if (summary) { + const safeSummary = summary.length > 500 ? summary.slice(0, 500) : summary + const delta: TeamStateDelta = { + _action: 'update', + members: [{ + name: memberId, + lastOutput: safeSummary, + lastOutputAt: now + }], + messages: [{ + from: memberId, + to: 'team-lead', + summary: safeSummary, + type: 'message', + timestamp: now + }], + updatedAt: now + } + result = result ? mergeDelta(result, delta) : delta + } + + continue + } + + // Plain text/markdown output from teammate — store as lastOutput + message history + const safeBody = body.length > 500 ? body.slice(0, 500) : body + const delta: TeamStateDelta = { + _action: 'update', + members: [{ + name: memberId, + lastOutput: safeBody, + lastOutputAt: now + }], + messages: [{ + from: memberId, + to: 'team-lead', + summary: safeBody, + type: 'message', + timestamp: now + }], + updatedAt: now + } + result = result ? mergeDelta(result, delta) : delta + } + + return result +} + +function extractUserToolResultBlocks(record: { role: string; content: unknown }): Array> { + let content: unknown = null + + if (record.role === 'user') { + content = record.content + } else if (record.role === 'agent' && isObject(record.content) && record.content.type === 'output') { + const data = isObject(record.content.data) ? record.content.data : null + if (data && data.type === 'user' && isObject(data.message)) { + content = data.message.content + } + } + + if (!Array.isArray(content)) return [] + + const blocks: Array> = [] + for (const block of content) { + if (!isObject(block)) continue + if (block.type !== 'tool_result') continue + blocks.push(block as Record) + } + + return blocks +} + +function extractTeamCreateResultDelta(record: { role: string; content: unknown }): TeamStateDelta | null { + const blocks = extractUserToolResultBlocks(record) + if (blocks.length === 0) return null + + for (const block of blocks) { + const payload = isObject(block.content) ? block.content : null + if (!payload) continue + + const teamName = typeof payload.team_name === 'string' ? payload.team_name : null + const description = typeof payload.description === 'string' ? payload.description : undefined + if (!teamName) continue + + return { + _action: 'update', + teamName, + description, + updatedAt: Date.now() + } + } + + return null +} + export function extractTeamStateFromMessageContent(messageContent: unknown): TeamStateDelta | null { const record = unwrapRoleWrappedRecordEnvelope(messageContent) if (!record) return null + // Check for teammate messages (permissions, output, idle, etc.) + const teammateDelta = extractTeammateMessage(record) + if (teammateDelta) return teammateDelta + + // TeamCreate may return a normalized/renamed team_name in tool_result content. + // Keep TeamState in sync with the effective runtime team identity. + const teamCreateResultDelta = extractTeamCreateResultDelta(record) + if (teamCreateResultDelta) return teamCreateResultDelta + if (record.role !== 'agent' && record.role !== 'assistant') return null if (!isObject(record.content) || typeof record.content.type !== 'string') return null @@ -184,9 +462,23 @@ export function extractTeamStateFromMessageContent(messageContent: unknown): Tea case 'TeamDelete': delta = processTeamDelete() break - case 'Task': - delta = processTaskToolWithTeam(block.input) + case 'Agent': { + // Only treat Agent calls with team_name as team member spawns. + // Agents without team_name (e.g. Explore, Plan) are standalone subagents. + const hasTeamName = typeof block.input.team_name === 'string' && block.input.team_name.length > 0 + if (hasTeamName) { + delta = processAgentSpawn(block.input) + } + break + } + case 'Task': { + // Legacy: Task tool with team_name is treated as agent spawn + const teamName = typeof block.input.team_name === 'string' ? block.input.team_name : null + if (teamName) { + delta = processAgentSpawn(block.input) + } break + } case 'TaskCreate': delta = processTaskCreate(block.input) break @@ -223,6 +515,9 @@ function mergeDelta(base: TeamStateDelta, incoming: TeamStateDelta): TeamStateDe if (incoming.messages) { merged.messages = [...(merged.messages ?? []), ...incoming.messages] } + if (incoming.pendingPermissions) { + merged.pendingPermissions = [...(merged.pendingPermissions ?? []), ...incoming.pendingPermissions] + } if (incoming.updatedAt) { merged.updatedAt = incoming.updatedAt } @@ -260,15 +555,22 @@ export function applyTeamStateDelta( } if (delta.tasks) { + const canonicalTaskId = (id: string): string => { + if (!id.startsWith('agent:')) return id + const at = id.indexOf('@', 'agent:'.length) + if (at === -1) return id + return id.slice(0, at) + } const taskMap = new Map((updated.tasks ?? []).map(t => [t.id, t])) for (const task of delta.tasks) { - const existing = taskMap.get(task.id) + const targetId = canonicalTaskId(task.id) + const existing = taskMap.get(task.id) ?? taskMap.get(targetId) if (existing) { - taskMap.set(task.id, { ...existing, ...task }) + taskMap.set(existing.id, { ...existing, ...task, id: existing.id }) } else if (task.title) { // Only insert new tasks that have a title (required by schema). // Orphan TaskUpdate without title is ignored to prevent schema validation failure. - taskMap.set(task.id, task) + taskMap.set(targetId, { ...task, id: targetId, title: task.title }) } } updated.tasks = Array.from(taskMap.values()) @@ -279,9 +581,25 @@ export function applyTeamStateDelta( updated.messages = [...msgs, ...delta.messages].slice(-50) } + if (delta.pendingPermissions) { + const permMap = new Map((updated.pendingPermissions ?? []).map(p => [p.requestId, p])) + for (const perm of delta.pendingPermissions) { + permMap.set(perm.requestId, perm) + } + updated.pendingPermissions = Array.from(permMap.values()) + } + if (delta.updatedAt) { updated.updatedAt = delta.updatedAt } + if (typeof delta.teamName === 'string' && delta.teamName.length > 0) { + updated.teamName = delta.teamName + } + + if (delta.description !== undefined) { + updated.description = delta.description + } + return updated } diff --git a/hub/src/web/routes/events.ts b/hub/src/web/routes/events.ts index 557b57db4..24fc7ab33 100644 --- a/hub/src/web/routes/events.ts +++ b/hub/src/web/routes/events.ts @@ -1,5 +1,4 @@ import { Hono } from 'hono' -import { streamSSE } from 'hono/streaming' import { randomUUID } from 'node:crypto' import { z } from 'zod' import type { SSEManager } from '../../sse/sseManager' @@ -32,6 +31,12 @@ const visibilitySchema = z.object({ visibility: z.enum(['visible', 'hidden']) }) +const encoder = new TextEncoder() + +function formatSSE(data: string): Uint8Array { + return encoder.encode(`data: ${data}\n\n`) +} + export function createEventsRoutes( getSseManager: () => SSEManager | null, getSyncEngine: () => SyncEngine | null, @@ -77,45 +82,80 @@ export function createEventsRoutes( } } - return streamSSE(c, async (stream) => { - manager.subscribe({ - id: subscriptionId, - namespace, - all, - sessionId: resolvedSessionId, - machineId, - visibility, - send: (event) => stream.writeSSE({ data: JSON.stringify(event) }), - sendHeartbeat: async () => { - await stream.writeSSE({ - data: JSON.stringify({ - type: 'heartbeat', - namespace, - data: { - timestamp: Date.now() - } - }) - }) - } - }) - - await stream.writeSSE({ - data: JSON.stringify({ - type: 'connection-changed', - data: { - status: 'connected', - subscriptionId + // Use a direct push-based ReadableStream instead of Hono's streamSSE + // which wraps through TransformStream and can cause buffering delays in Bun. + let streamController: ReadableStreamDefaultController | null = null + let closed = false + + const body = new ReadableStream({ + start(controller) { + streamController = controller + + manager.subscribe({ + id: subscriptionId, + namespace, + all, + sessionId: resolvedSessionId, + machineId, + visibility, + send: (event) => { + if (closed) return + try { + controller.enqueue(formatSSE(JSON.stringify(event))) + } catch { + // Stream closed + } + }, + sendHeartbeat: () => { + if (closed) return + try { + controller.enqueue(formatSSE(JSON.stringify({ + type: 'heartbeat', + namespace, + data: { timestamp: Date.now() } + }))) + } catch { + // Stream closed + } } }) - }) - await new Promise((resolve) => { - const done = () => resolve() - c.req.raw.signal.addEventListener('abort', done, { once: true }) - stream.onAbort(done) - }) + // Send initial connection event + try { + controller.enqueue(formatSSE(JSON.stringify({ + type: 'connection-changed', + data: { status: 'connected', subscriptionId } + }))) + } catch { + // Stream closed + } + }, + cancel() { + closed = true + streamController = null + manager.unsubscribe(subscriptionId) + } + }) + // Also handle request abort + c.req.raw.signal.addEventListener('abort', () => { + closed = true manager.unsubscribe(subscriptionId) + try { + streamController?.close() + } catch { + // Already closed + } + streamController = null + }, { once: true }) + + return new Response(body, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + } }) }) diff --git a/hub/src/web/routes/permissions.ts b/hub/src/web/routes/permissions.ts index 3173ae84b..6fe82e777 100644 --- a/hub/src/web/routes/permissions.ts +++ b/hub/src/web/routes/permissions.ts @@ -1,5 +1,6 @@ -import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' +import { isPermissionModeAllowedForFlavor, isObject } from '@hapi/protocol' import { PermissionModeSchema } from '@hapi/protocol/schemas' +import type { TeamPermission, TeamState } from '@hapi/protocol/types' import { Hono } from 'hono' import { z } from 'zod' import type { SyncEngine } from '../../sync/syncEngine' @@ -26,6 +27,74 @@ const denyBodySchema = z.object({ decision: decisionSchema.optional() }) +/** + * Update the teamState.pendingPermissions status for a given requestId (toolUseId). + * This keeps the TeamPanel UI in sync after API-based approval/denial. + */ +function updateTeamPermissionStatus( + engine: SyncEngine, + sessionId: string, + session: { teamState?: TeamState | null; namespace: string }, + requestId: string, + status: 'approved' | 'denied' +): void { + const teamState = session.teamState as TeamState | null | undefined + if (!teamState?.pendingPermissions?.length) return + + const updated = teamState.pendingPermissions.map(p => + (p.requestId === requestId || p.toolUseId === requestId) + ? { ...p, status: status as 'approved' | 'denied' } + : p + ) + + // Only persist if something actually changed + if (updated.every((p, i) => p === teamState.pendingPermissions![i])) return + + const newTeamState = { ...teamState, pendingPermissions: updated, updatedAt: Date.now() } + engine.updateSessionTeamState(sessionId, newTeamState, session.namespace) +} + +/** + * Resolve the correct agentState.requests key for a permission request. + * + * The requestId from the web UI may be a teammate message ID ("perm-...") or + * SDK tool_use_id ("toolu_...") that doesn't match the agentState.requests key. + * Try to resolve via teamState.pendingPermissions.toolUseId, which is updated + * by syncAgentPermissionsToTeamState to point to the agentState.requests key. + */ +function resolveAgentRequestId( + requestId: string, + requests: Record | null, + teamState: TeamState | null | undefined +): { agentRequestId: string | null; teamPerm: TeamPermission | null } { + // Direct match + if (requests && requests[requestId]) { + return { agentRequestId: requestId, teamPerm: null } + } + + // Try resolving via teamState + const teamPerm = teamState?.pendingPermissions?.find( + p => p.requestId === requestId || p.toolUseId === requestId + ) ?? null + + if (teamPerm) { + if (teamPerm.toolUseId && requests?.[teamPerm.toolUseId]) { + return { agentRequestId: teamPerm.toolUseId, teamPerm } + } + if (teamPerm.requestId && requests?.[teamPerm.requestId]) { + return { agentRequestId: teamPerm.requestId, teamPerm } + } + // Last resort: find by tool name match in agentState.requests + for (const [key, req] of Object.entries(requests ?? {})) { + if (isObject(req) && req.tool === teamPerm.toolName) { + return { agentRequestId: key, teamPerm } + } + } + } + + return { agentRequestId: null, teamPerm } +} + export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -50,22 +119,27 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): } const requests = session.agentState?.requests ?? null - if (!requests || !requests[requestId]) { - return c.json({ error: 'Request not found' }, 404) - } - - const mode = parsed.data.mode - if (mode !== undefined) { - const flavor = session.metadata?.flavor ?? 'claude' - if (!isPermissionModeAllowedForFlavor(mode, flavor)) { - return c.json({ error: 'Invalid permission mode for session flavor' }, 400) + const teamState = session.teamState as TeamState | null | undefined + const { agentRequestId, teamPerm } = resolveAgentRequestId(requestId, requests, teamState) + + if (agentRequestId) { + // RPC path: send approval directly to the CLI agent via RPC + const mode = parsed.data.mode + if (mode !== undefined) { + const flavor = session.metadata?.flavor ?? 'claude' + if (!isPermissionModeAllowedForFlavor(mode, flavor)) { + return c.json({ error: 'Invalid permission mode for session flavor' }, 400) + } } + await engine.approvePermission(sessionId, agentRequestId, mode, parsed.data.allowTools, parsed.data.decision, parsed.data.answers) + updateTeamPermissionStatus(engine, sessionId, session, requestId, 'approved') + return c.json({ ok: true }) } - const allowTools = parsed.data.allowTools - const decision = parsed.data.decision - const answers = parsed.data.answers - await engine.approvePermission(sessionId, requestId, mode, allowTools, decision, answers) - return c.json({ ok: true }) + + // Team permissions are resolved internally by the team lead agent. + // No external approval path exists. + + return c.json({ error: 'Request not found' }, 404) }) app.post('/sessions/:id/permissions/:requestId/deny', async (c) => { @@ -82,19 +156,25 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): } const { sessionId, session } = sessionResult - const requests = session.agentState?.requests ?? null - if (!requests || !requests[requestId]) { - return c.json({ error: 'Request not found' }, 404) - } - const json = await c.req.json().catch(() => null) const parsed = denyBodySchema.safeParse(json ?? {}) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } - await engine.denyPermission(sessionId, requestId, parsed.data.decision) - return c.json({ ok: true }) + const requests = session.agentState?.requests ?? null + const teamState = session.teamState as TeamState | null | undefined + const { agentRequestId, teamPerm } = resolveAgentRequestId(requestId, requests, teamState) + + if (agentRequestId) { + await engine.denyPermission(sessionId, agentRequestId, parsed.data.decision) + updateTeamPermissionStatus(engine, sessionId, session, requestId, 'denied') + return c.json({ ok: true }) + } + + // Team permissions are resolved internally by the team lead agent. + + return c.json({ error: 'Request not found' }, 404) }) return app diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 55e8cea67..fb9a02fd9 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -101,7 +101,13 @@ export const TodosSchema = z.array(TodoItemSchema) export const TeamMemberSchema = z.object({ name: z.string(), agentType: z.string().optional(), - status: z.enum(['active', 'idle', 'shutdown']).optional() + status: z.enum(['active', 'idle', 'completed', 'error', 'shutdown']).optional(), + runInBackground: z.boolean().optional(), + isolation: z.enum(['worktree']).optional(), + description: z.string().optional(), + agentId: z.string().optional(), + lastOutput: z.string().optional(), + lastOutputAt: z.number().optional() }) export type TeamMember = z.infer @@ -126,12 +132,26 @@ export const TeamMessageSchema = z.object({ export type TeamMessage = z.infer +export const TeamPermissionSchema = z.object({ + requestId: z.string(), + toolUseId: z.string().optional(), + memberName: z.string(), + toolName: z.string(), + description: z.string().optional(), + input: z.unknown().optional(), + createdAt: z.number(), + status: z.enum(['pending', 'approved', 'denied']).optional() +}) + +export type TeamPermission = z.infer + export const TeamStateSchema = z.object({ teamName: z.string(), description: z.string().optional(), members: z.array(TeamMemberSchema).optional(), tasks: z.array(TeamTaskSchema).optional(), messages: z.array(TeamMessageSchema).optional(), + pendingPermissions: z.array(TeamPermissionSchema).optional(), updatedAt: z.number().optional() }) diff --git a/shared/src/types.ts b/shared/src/types.ts index d6d5fd3e9..77d1fa841 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -9,6 +9,7 @@ export type { SyncEvent, TeamMember, TeamMessage, + TeamPermission, TeamState, TeamTask, TodoItem, diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 22c9e765a..32791e2e2 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -4,6 +4,35 @@ import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/c import { parseMessageAsEvent } from '@/chat/reducerEvents' import { ensureToolBlock, extractTitleFromChangeTitleInput, isChangeTitleToolName, type PermissionEntry } from '@/chat/reducerTools' +function extractToolResultText(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((item) => { + if (typeof item === 'string') return item + if (!item || typeof item !== 'object') return '' + if ('text' in item && typeof (item as { text?: unknown }).text === 'string') { + return (item as { text: string }).text + } + if ('content' in item && typeof (item as { content?: unknown }).content === 'string') { + return (item as { content: string }).content + } + return '' + }) + .filter(Boolean) + .join('\n') + } + if (content && typeof content === 'object') { + if ('text' in content && typeof (content as { text?: unknown }).text === 'string') { + return (content as { text: string }).text + } + if ('content' in content && typeof (content as { content?: unknown }).content === 'string') { + return (content as { content: string }).content + } + } + return '' +} + export function reduceTimeline( messages: TracedMessage[], context: { @@ -216,7 +245,11 @@ export function reduceTimeline( block.tool.result = c.content block.tool.completedAt = msg.createdAt - block.tool.state = c.is_error ? 'error' : 'completed' + const resultText = extractToolResultText(c.content) + const isBenignTaskOutputMiss = c.is_error + && block.tool.name === 'TaskOutput' + && /No task found with ID:/i.test(resultText) + block.tool.state = c.is_error && !isBenignTaskOutputMiss ? 'error' : 'completed' continue } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 235ce299c..32d35a521 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1,4 +1,5 @@ import { getCodexCollaborationModeOptions, getPermissionModeOptionsForFlavor } from '@hapi/protocol' +import type { TeamState } from '@hapi/protocol/types' import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react' import { type ChangeEvent as ReactChangeEvent, @@ -45,6 +46,7 @@ export function HappyComposer(props: { active?: boolean allowSendWhenInactive?: boolean thinking?: boolean + teamState?: TeamState agentState?: AgentState | null contextSize?: number controlledByUser?: boolean @@ -71,6 +73,7 @@ export function HappyComposer(props: { active = true, allowSendWhenInactive = false, thinking = false, + teamState, agentState, contextSize, controlledByUser = false, @@ -600,6 +603,7 @@ export function HappyComposer(props: { = { function getConnectionStatus( active: boolean, thinking: boolean, + teamBusy: boolean, agentState: AgentState | null | undefined, voiceStatus: ConversationStatus | undefined, t: (key: string) => string @@ -74,10 +76,10 @@ function getConnectionStatus( } } - if (thinking) { + if (thinking || teamBusy) { const vibingMessage = VIBING_MESSAGES[Math.floor(Math.random() * VIBING_MESSAGES.length)].toLowerCase() + '…' return { - text: vibingMessage, + text: thinking ? vibingMessage : t('session.item.thinking'), color: 'text-[#007AFF]', dotColor: 'bg-[#007AFF]', isPulsing: true @@ -109,6 +111,7 @@ function getContextWarning(contextSize: number, maxContextSize: number, t: (key: export function StatusBar(props: { active: boolean thinking: boolean + teamState?: TeamState agentState: AgentState | null | undefined contextSize?: number model?: string | null @@ -118,9 +121,17 @@ export function StatusBar(props: { voiceStatus?: ConversationStatus }) { const { t } = useTranslation() + const teamBusy = useMemo(() => { + const state = props.teamState + if (!state) return false + const activeMembers = (state.members ?? []).some(member => member.status === 'active') + const activeTasks = (state.tasks ?? []).some(task => task.status === 'in_progress') + return activeMembers || activeTasks + }, [props.teamState]) + const connectionStatus = useMemo( - () => getConnectionStatus(props.active, props.thinking, props.agentState, props.voiceStatus, t), - [props.active, props.thinking, props.agentState, props.voiceStatus, t] + () => getConnectionStatus(props.active, props.thinking, teamBusy, props.agentState, props.voiceStatus, t), + [props.active, props.thinking, teamBusy, props.agentState, props.voiceStatus, t] ) const contextWarning = useMemo( diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index c435447c6..7bc752d2e 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -17,6 +17,7 @@ import { TeamPanel } from '@/components/TeamPanel' import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { useVoiceOptional } from '@/lib/voice-context' +import { useToast } from '@/lib/toast-context' import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' export function SessionChat(props: { @@ -109,6 +110,26 @@ export function SessionChat(props: { prevThinkingRef.current = props.session.thinking }, [props.session.thinking, props.session.id]) + // Notify when teammate permission requests appear + const { addToast } = useToast() + const prevTeamPermIdsRef = useRef>(new Set()) + + useEffect(() => { + const perms = props.session.teamState?.pendingPermissions ?? [] + const pending = perms.filter(p => p.status === 'pending') + for (const perm of pending) { + if (!prevTeamPermIdsRef.current.has(perm.requestId)) { + addToast({ + title: `${perm.memberName} requests approval`, + body: `Tool: ${perm.toolName}${perm.description ? ` — ${perm.description}` : ''}`, + sessionId: props.session.id, + url: '' + }) + } + } + prevTeamPermIdsRef.current = new Set(pending.map(p => p.requestId)) + }, [props.session.teamState?.pendingPermissions, props.session.id, addToast]) + // Report permission requests to voice assistant // Note: voiceHooks internally checks isVoiceSessionStarted() so we don't need to check voice.status here const prevRequestIdsRef = useRef>(new Set()) @@ -290,7 +311,13 @@ export function SessionChat(props: { /> {props.session.teamState && ( - + )} {sessionInactive ? ( @@ -334,6 +361,7 @@ export function SessionChat(props: { active={props.session.active} allowSendWhenInactive thinking={props.session.thinking} + teamState={props.session.teamState} agentState={props.session.agentState} contextSize={reduced.latestUsage?.contextSize} controlledByUser={controlledByUser} diff --git a/web/src/components/TeamPanel.tsx b/web/src/components/TeamPanel.tsx index da0653492..845b4b8d8 100644 --- a/web/src/components/TeamPanel.tsx +++ b/web/src/components/TeamPanel.tsx @@ -1,57 +1,477 @@ -import { useState } from 'react' -import type { TeamState } from '@hapi/protocol/types' +import { useMemo, useState } from 'react' +import type { TeamState, TeamMember, TeamTask, TeamMessage, TeamPermission, DecryptedMessage } from '@hapi/protocol/types' +import type { ApiClient } from '@/api/client' +import { isObject } from '@hapi/protocol' -function memberStatusDot(status?: string): string { - if (status === 'active') return 'bg-emerald-500' - if (status === 'shutdown') return 'bg-red-500' - return 'bg-gray-400' +// --- Teammate activity extraction from conversation messages --- + +type TeammateActivity = { + memberName: string + toolCalls: Array<{ name: string; description: string | null; id: string }> + lastOutput: string | null + timestamp: number +} + +/** + * Extract per-member activity from conversation messages by looking at + * Agent tool calls (which spawn subagents) and their tool_result blocks. + */ +function extractTeammateActivities(messages: DecryptedMessage[]): Map { + const activities = new Map() + + for (const msg of messages) { + const content = msg.content + if (!isObject(content) || content.type !== 'output') continue + const data = isObject(content.data) ? content.data : null + if (!data) continue + + if (data.type === 'assistant') { + const message = isObject(data.message) ? data.message : null + if (!message) continue + const blocks = Array.isArray(message.content) ? message.content : [] + for (const block of blocks) { + if (!isObject(block)) continue + // Agent tool call - spawning a subagent + if (block.type === 'tool_use' && block.name === 'Agent') { + const input = isObject(block.input) ? block.input as Record : null + if (!input) continue + const name = typeof input.name === 'string' ? input.name : null + if (!name) continue + const desc = typeof input.description === 'string' ? input.description : null + if (!activities.has(name)) { + activities.set(name, { + memberName: name, + toolCalls: [], + lastOutput: null, + timestamp: msg.createdAt + }) + } + const activity = activities.get(name)! + activity.timestamp = msg.createdAt + if (desc) { + activity.toolCalls = [{ name: 'Agent', description: desc, id: typeof block.id === 'string' ? block.id : '' }] + } + } + } + } + + // Tool results for Agent calls contain the subagent's output + if (data.type === 'user') { + const message = isObject(data.message) ? data.message : null + if (!message) continue + const blocks = Array.isArray(message.content) ? message.content : [] + for (const block of blocks) { + if (!isObject(block) || block.type !== 'tool_result') continue + const rawContent = 'content' in block ? block.content : null + if (typeof rawContent !== 'string') continue + // Try to match this result to a known member + for (const [name, activity] of activities) { + const toolId = activity.toolCalls[0]?.id + if (toolId && typeof block.tool_use_id === 'string' && block.tool_use_id === toolId) { + // Truncate long outputs + activity.lastOutput = rawContent.length > 500 ? rawContent.slice(-500) : rawContent + activity.timestamp = msg.createdAt + } + } + } + } + } + + return activities +} + +// --- Styling helpers --- + +function memberStatusColor(status?: string): string { + switch (status) { + case 'active': return 'bg-blue-500' + case 'idle': return 'bg-emerald-500' + case 'completed': return 'bg-blue-500' + case 'error': return 'bg-red-500' + case 'shutdown': return 'bg-gray-400' + default: return 'bg-gray-400' + } } -function taskStatusColor(status?: string): string { - if (status === 'completed') return 'text-emerald-600' - if (status === 'in_progress') return 'text-[var(--app-link)]' - if (status === 'blocked') return 'text-red-500' - return 'text-[var(--app-hint)]' +function memberStatusLabel(status?: string): string { + switch (status) { + case 'active': return 'In Progress' + case 'idle': return 'Idle' + case 'completed': return 'Done' + case 'error': return 'Error' + case 'shutdown': return 'Stopped' + default: return 'Unknown' + } } function taskStatusIcon(status?: string): string { - if (status === 'completed') return '\u2611' - if (status === 'in_progress') return '\u25b6' - if (status === 'blocked') return '\u26a0' - return '\u2610' + switch (status) { + case 'completed': return '\u2713' + case 'in_progress': return '\u25CF' + case 'blocked': return '\u26A0' + default: return '\u25CB' + } +} + +function taskStatusClass(status?: string): string { + switch (status) { + case 'completed': return 'text-emerald-500' + case 'in_progress': return 'text-[var(--app-link)]' + case 'blocked': return 'text-red-500' + default: return 'text-[var(--app-hint)]' + } +} + +// --- Components --- + +function PermissionCard({ permission, onApprove, onDeny }: { + permission: TeamPermission + onApprove: () => void | Promise + onDeny: () => void | Promise +}) { + const [acted, setActed] = useState<'approve' | 'deny' | null>(null) + const [loading, setLoading] = useState(false) + + const handleApprove = async () => { + setLoading(true) + setActed('approve') + try { + await onApprove() + } finally { + setLoading(false) + } + } + + const handleDeny = async () => { + setLoading(true) + setActed('deny') + try { + await onDeny() + } finally { + setLoading(false) + } + } + + if (acted) { + return ( +
+
+ {acted === 'approve' ? '✓' : '✕'} + {permission.toolName} — {acted === 'approve' ? 'allowed' : 'denied'} +
+
+ ) + } + + return ( +
+
+ 🔐 + {permission.toolName} +
+ {permission.description && ( +
+ {permission.description} +
+ )} +
+ + +
+
+ ) } -export function TeamPanel(props: { teamState: TeamState }) { +function MemberCard({ member, activity, permissions, onApprovePermission, onDenyPermission }: { + member: TeamMember + activity?: TeammateActivity + permissions: TeamPermission[] + onApprovePermission?: (permission: TeamPermission) => void + onDenyPermission?: (permission: TeamPermission) => void +}) { const [expanded, setExpanded] = useState(false) - const { teamState } = props + const isActive = member.status === 'active' + const hasPendingPerms = permissions.length > 0 + + // Auto-expand when there are pending permissions + const shouldExpand = expanded || hasPendingPerms + + return ( +
+ + + {shouldExpand && ( +
+ {/* Pending permissions */} + {permissions.map(perm => ( + onApprovePermission?.(perm)} + onDeny={() => onDenyPermission?.(perm)} + /> + ))} + + {activity?.toolCalls?.[0]?.description && ( +
0 ? 'mt-1.5' : ''} text-[11px] text-[var(--app-fg)]`}> + Task: {activity.toolCalls[0].description} +
+ )} + {(() => { + // Prefer real-time lastOutput from TeamState, fallback to activity extraction + const output = member.lastOutput ?? activity?.lastOutput + if (output) { + return ( +
+
+                                        {output}
+                                    
+
+ ) + } + if (!hasPendingPerms) { + return ( +
+ {isActive ? 'Working...' : 'No output yet'} +
+ ) + } + return null + })()} +
+ )} +
+ ) +} + +function TaskItem({ task }: { task: TeamTask }) { + return ( +
+ + {taskStatusIcon(task.status)} + + + {task.title} + + {task.owner && ( + + {task.owner} + + )} +
+ ) +} + +function MessageItem({ msg }: { msg: TeamMessage }) { + const time = new Date(msg.timestamp) + const timeStr = `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}` + + const typeIcon = msg.type === 'broadcast' ? '\uD83D\uDCE2' + : msg.type === 'shutdown_request' ? '\u26D4' + : msg.type === 'shutdown_response' ? '\u2705' + : '\uD83D\uDCAC' + + return ( +
+ {timeStr} + {typeIcon} +
+ {msg.from} + + {msg.to} + {msg.summary && ( + : {msg.summary} + )} +
+
+ ) +} + +function ProgressBar({ completed, total }: { completed: number; total: number }) { + const pct = total > 0 ? Math.round((completed / total) * 100) : 0 + return ( +
+
+
+
+ + {completed}/{total} + +
+ ) +} + +export function TeamPanel(props: { + teamState: TeamState + messages?: DecryptedMessage[] + api?: ApiClient + sessionId?: string + onSend?: (text: string) => void +}) { + const { teamState, messages: conversationMessages } = props const members = teamState.members ?? [] const tasks = teamState.tasks ?? [] const messages = teamState.messages ?? [] + const pendingPermissions = (teamState.pendingPermissions ?? []).filter(p => p.status === 'pending') - const completedTasks = tasks.filter(t => t.status === 'completed').length const activeMembers = members.filter(m => m.status === 'active').length + const completedTasks = tasks.filter(t => t.status === 'completed').length + const hasActivity = activeMembers > 0 || tasks.some(t => t.status === 'in_progress') + const totalPendingPerms = pendingPermissions.length + + const activities = useMemo( + () => conversationMessages ? extractTeammateActivities(conversationMessages) : new Map(), + [conversationMessages] + ) + + const memberPermissions = useMemo(() => { + const map = new Map() + for (const perm of pendingPermissions) { + const existing = map.get(perm.memberName) ?? [] + existing.push(perm) + map.set(perm.memberName, existing) + } + return map + }, [pendingPermissions]) + + const handleApprovePermission = async (perm: TeamPermission) => { + // Use toolUseId (which matches agentState.requests) if available, fallback to requestId + const permId = perm.toolUseId ?? perm.requestId + if (props.api && props.sessionId) { + try { + await props.api.approvePermission(props.sessionId, permId) + return + } catch { + // API failed (e.g. request not found in agentState.requests), + // fall back to sending text message to the lead + } + } + props.onSend?.(`Approve ${perm.memberName}'s permission request to use ${perm.toolName}. Request ID: ${perm.requestId}`) + } + + const handleDenyPermission = async (perm: TeamPermission) => { + const permId = perm.toolUseId ?? perm.requestId + if (props.api && props.sessionId) { + try { + await props.api.denyPermission(props.sessionId, permId) + return + } catch { + // Fall back to text message + } + } + props.onSend?.(`Deny ${perm.memberName}'s permission request to use ${perm.toolName}. Request ID: ${perm.requestId}`) + } + + // Default expanded when there's active work or pending permissions + const [expanded, setExpanded] = useState(hasActivity || totalPendingPerms > 0) return ( -
+