diff --git a/cli/src/claude/utils/getToolName.ts b/cli/src/claude/utils/getToolName.ts index f14e1fae..cb877645 100644 --- a/cli/src/claude/utils/getToolName.ts +++ b/cli/src/claude/utils/getToolName.ts @@ -28,7 +28,13 @@ const STANDARD_TOOLS: Record = { 'TodoWrite': 'Update Tasks', 'TodoRead': 'Read Tasks', 'Task': 'Launch Agent', - + + // Team management + 'TeamCreate': 'Create Team', + 'TeamDelete': 'Delete Team', + 'SendMessage': 'Send Message', + 'EnterWorktree': 'Enter Worktree', + // Web tools 'WebFetch': 'Fetch Web Page', 'WebSearch': 'Search Web', diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index fd8b15f2..179a5fb4 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -5,6 +5,7 @@ import type { ModelMode, PermissionMode } from '@hapi/protocol/types' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' +import { extractTeamStateFromMessageContent, applyTeamStateDelta } from '../../../sync/teams' import type { CliSocketWithData } from '../../socketTypes' import type { AccessErrorReason, AccessResult } from './types' @@ -95,6 +96,17 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } } + const teamDelta = extractTeamStateFromMessageContent(content) + if (teamDelta) { + const existingSession = store.sessions.getSession(sid) + 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) + if (updated) { + onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } }) + } + } + const update = { id: randomUUID(), seq: msg.seq, diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 94e59675..cfb59a8b 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -22,7 +22,7 @@ export { PushStore } from './pushStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 3 +const SCHEMA_VERSION: number = 4 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -110,6 +110,12 @@ export class Store { return } + if (currentVersion === 3 && SCHEMA_VERSION === 4) { + this.migrateFromV3ToV4() + this.setUserVersion(SCHEMA_VERSION) + return + } + if (currentVersion !== SCHEMA_VERSION) { throw this.buildSchemaMismatchError(currentVersion) } @@ -132,6 +138,8 @@ export class Store { agent_state_version INTEGER DEFAULT 1, todos TEXT, todos_updated_at INTEGER, + team_state TEXT, + team_state_updated_at INTEGER, active INTEGER DEFAULT 0, active_at INTEGER, seq INTEGER DEFAULT 0 @@ -280,6 +288,21 @@ export class Store { return } + private migrateFromV3ToV4(): void { + const columns = this.getSessionColumnNames() + if (!columns.has('team_state')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN team_state TEXT') + } + if (!columns.has('team_state_updated_at')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN team_state_updated_at INTEGER') + } + } + + private getSessionColumnNames(): Set { + const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> + return new Set(rows.map((row) => row.name)) + } + private getMachineColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index b8093c52..a4e1090a 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -8,6 +8,7 @@ import { getSessionByNamespace, getSessions, getSessionsByNamespace, + setSessionTeamState, setSessionTodos, updateSessionAgentState, updateSessionMetadata @@ -47,6 +48,10 @@ export class SessionStore { return setSessionTodos(this.db, id, todos, todosUpdatedAt, namespace) } + setSessionTeamState(id: string, teamState: unknown, updatedAt: number, namespace: string): boolean { + return setSessionTeamState(this.db, id, teamState, updatedAt, namespace) + } + getSession(id: string): StoredSession | null { return getSession(this.db, id) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index c280134d..164b2adf 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -18,6 +18,8 @@ type DbSessionRow = { agent_state_version: number todos: string | null todos_updated_at: number | null + team_state: string | null + team_state_updated_at: number | null active: number active_at: number | null seq: number @@ -37,6 +39,8 @@ function toStoredSession(row: DbSessionRow): StoredSession { agentStateVersion: row.agent_state_version, todos: safeJsonParse(row.todos), todosUpdatedAt: row.todos_updated_at, + teamState: safeJsonParse(row.team_state), + teamStateUpdatedAt: row.team_state_updated_at, active: row.active === 1, activeAt: row.active_at, seq: row.seq @@ -189,6 +193,38 @@ export function setSessionTodos( } } +export function setSessionTeamState( + db: Database, + id: string, + teamState: unknown, + updatedAt: number, + namespace: string +): boolean { + try { + const json = teamState === null || teamState === undefined ? null : JSON.stringify(teamState) + const result = db.prepare(` + UPDATE sessions + SET team_state = @team_state, + team_state_updated_at = @team_state_updated_at, + updated_at = CASE WHEN updated_at > @updated_at THEN updated_at ELSE @updated_at END, + seq = seq + 1 + WHERE id = @id + AND namespace = @namespace + AND (team_state_updated_at IS NULL OR team_state_updated_at < @team_state_updated_at) + `).run({ + id, + team_state: json, + team_state_updated_at: updatedAt, + updated_at: updatedAt, + namespace + }) + + return result.changes === 1 + } catch { + return false + } +} + export function getSession(db: Database, id: string): StoredSession | null { const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined return row ? toStoredSession(row) : null diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 9ef422ac..56156492 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -11,6 +11,8 @@ export type StoredSession = { agentStateVersion: number todos: unknown | null todosUpdatedAt: number | null + teamState: unknown | null + teamStateUpdatedAt: number | null active: boolean activeAt: number | null seq: number diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 05d32e26..c9a0c549 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1,4 +1,4 @@ -import { AgentStateSchema, MetadataSchema } from '@hapi/protocol/schemas' +import { AgentStateSchema, MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas' import type { ModelMode, PermissionMode, Session } from '@hapi/protocol/types' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' @@ -104,6 +104,12 @@ export class SessionCache { return parsed.success ? parsed.data : undefined })() + const teamState = (() => { + if (stored.teamState === null || stored.teamState === undefined) return undefined + const parsed = TeamStateSchema.safeParse(stored.teamState) + return parsed.success ? parsed.data : undefined + })() + const session: Session = { id: stored.id, namespace: stored.namespace, @@ -119,6 +125,7 @@ export class SessionCache { thinking: existing?.thinking ?? false, thinkingAt: existing?.thinkingAt ?? 0, todos, + teamState, permissionMode: existing?.permissionMode, modelMode: existing?.modelMode } @@ -327,6 +334,15 @@ export class SessionCache { ) } + if (oldStored.teamState !== null && oldStored.teamStateUpdatedAt !== null) { + this.store.sessions.setSessionTeamState( + newSessionId, + oldStored.teamState, + oldStored.teamStateUpdatedAt, + namespace + ) + } + const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) if (!deleted) { throw new Error('Failed to delete old session during merge') diff --git a/hub/src/sync/teams.test.ts b/hub/src/sync/teams.test.ts new file mode 100644 index 00000000..968c6b10 --- /dev/null +++ b/hub/src/sync/teams.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect } from 'bun:test' +import { applyTeamStateDelta } from './teams' +import type { TeamState, TeamTask } from '@hapi/protocol/types' + +const baseTeamState: TeamState = { + teamName: 'test-team', + members: [{ name: 'lead', status: 'active' }], + tasks: [], + messages: [], + updatedAt: 1000 +} + +function getTasks(result: TeamState | null | undefined): TeamTask[] { + expect(result).toBeTruthy() + return result!.tasks ?? [] +} + +describe('applyTeamStateDelta - orphan TaskUpdate', () => { + test('should skip inserting task without title (orphan TaskUpdate)', () => { + const result = applyTeamStateDelta(baseTeamState, { + tasks: [{ id: 'task-1', status: 'in_progress' } as any], + updatedAt: 2000 + }) + + expect(getTasks(result)).toEqual([]) + }) + + test('should insert task when title is present (normal TaskCreate)', () => { + const result = applyTeamStateDelta(baseTeamState, { + tasks: [{ id: 'task-1', title: 'Do something', status: 'pending' }], + updatedAt: 2000 + }) + + const tasks = getTasks(result) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ title: 'Do something' }) + }) + + test('should update existing task even without title (normal TaskUpdate)', () => { + const stateWithTask: TeamState = { + ...baseTeamState, + tasks: [{ id: 'task-1', title: 'Do something', status: 'pending' }] + } + + const result = applyTeamStateDelta(stateWithTask, { + tasks: [{ id: 'task-1', status: 'completed' } as any], + updatedAt: 2000 + }) + + const tasks = getTasks(result) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ title: 'Do something', status: 'completed' }) + }) + + test('should handle mixed: existing task update + orphan new task', () => { + const stateWithTask: TeamState = { + ...baseTeamState, + tasks: [{ id: 'task-1', title: 'Existing task', status: 'pending' }] + } + + const result = applyTeamStateDelta(stateWithTask, { + tasks: [ + { id: 'task-1', status: 'in_progress' } as any, + { id: 'task-2', status: 'completed' } as any, + ], + updatedAt: 2000 + }) + + const tasks = getTasks(result) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ id: 'task-1', status: 'in_progress' }) + }) +}) diff --git a/hub/src/sync/teams.ts b/hub/src/sync/teams.ts new file mode 100644 index 00000000..941f7d13 --- /dev/null +++ b/hub/src/sync/teams.ts @@ -0,0 +1,287 @@ +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' } + +function extractToolBlocks(content: Record): Array<{ name: string; input: Record }> { + const blocks: Array<{ name: string; input: Record }> = [] + + // Claude output format: { type: 'output', data: { type: 'assistant', message: { content: [...] } } } + if (content.type === 'output') { + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'assistant') return blocks + + const message = isObject(data.message) ? data.message : null + if (!message) return blocks + + const modelContent = message.content + if (!Array.isArray(modelContent)) return blocks + + for (const block of modelContent) { + if (!isObject(block) || block.type !== 'tool_use') continue + const name = typeof block.name === 'string' ? block.name : null + if (!name) continue + const input = isObject(block.input) ? block.input as Record : null + if (!input) continue + blocks.push({ name, input }) + } + } + + // Codex format: { type: 'codex', data: { type: 'tool-call', name: '...', input: {...} } } + if (content.type === 'codex') { + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'tool-call') return blocks + const name = typeof data.name === 'string' ? data.name : null + if (!name) return blocks + const input = isObject(data.input) ? data.input as Record : null + if (!input) return blocks + blocks.push({ name, input }) + } + + return blocks +} + +function processTeamCreate(input: Record): TeamStateDelta | null { + const teamName = typeof input.team_name === 'string' ? input.team_name : null + if (!teamName) return null + + return { + _action: 'create', + teamName, + description: typeof input.description === 'string' ? input.description : undefined, + members: [], + tasks: [], + messages: [], + updatedAt: Date.now() + } +} + +function processTeamDelete(): TeamStateDelta { + return { _action: 'delete' } +} + +function processTaskToolWithTeam(input: Record): TeamStateDelta | null { + const teamName = typeof input.team_name === 'string' ? input.team_name : null + const name = typeof input.name === 'string' ? input.name : null + if (!teamName || !name) return null + + const agentType = typeof input.subagent_type === 'string' ? input.subagent_type : undefined + const description = typeof input.description === 'string' ? input.description : null + + const delta: TeamStateDelta = { + _action: 'update', + members: [{ name, agentType, status: 'active' }], + updatedAt: Date.now() + } + + // Also track the spawned agent's work as a task + if (description) { + delta.tasks = [{ + id: `agent:${name}`, + title: description, + status: 'in_progress', + owner: name + }] + } + + return delta +} + +function processTaskCreate(input: Record): TeamStateDelta | null { + const id = typeof input.task_id === 'string' ? input.task_id + : typeof input.id === 'string' ? input.id + : null + const title = typeof input.title === 'string' ? input.title + : typeof input.content === 'string' ? input.content + : null + if (!id || !title) return null + + const description = typeof input.description === 'string' ? input.description : undefined + const status = typeof input.status === 'string' ? input.status as 'pending' | 'in_progress' | 'completed' | 'blocked' : 'pending' + const owner = typeof input.owner === 'string' ? input.owner : undefined + + return { + _action: 'update', + tasks: [{ id, title, description, status, owner }], + updatedAt: Date.now() + } +} + +function processTaskUpdate(input: Record): TeamStateDelta | null { + const id = typeof input.task_id === 'string' ? input.task_id + : typeof input.id === 'string' ? input.id + : null + if (!id) return null + + const task: Record = { id } + if (typeof input.title === 'string') task.title = input.title + if (typeof input.status === 'string') task.status = input.status + if (typeof input.owner === 'string') task.owner = input.owner + if (typeof input.description === 'string') task.description = input.description + + // Must have at least one field besides id + if (Object.keys(task).length <= 1) return null + + return { + _action: 'update', + tasks: [task as { id: string; title: string; status?: 'pending' | 'in_progress' | 'completed' | 'blocked'; owner?: string }], + updatedAt: Date.now() + } +} + +function processSendMessage(input: Record): TeamStateDelta | null { + const type = typeof input.type === 'string' ? input.type : null + if (!type) return null + + const summary = typeof input.summary === 'string' ? input.summary : '' + const recipient = typeof input.recipient === 'string' ? input.recipient : 'all' + + const validTypes = ['message', 'broadcast', 'shutdown_request', 'shutdown_response'] as const + const msgType = validTypes.includes(type as typeof validTypes[number]) + ? type as typeof validTypes[number] + : 'message' + + const delta: TeamStateDelta = { + _action: 'update', + messages: [{ + from: 'team-lead', + to: msgType === 'broadcast' ? 'all' : recipient, + summary, + type: msgType, + timestamp: Date.now() + }], + updatedAt: Date.now() + } + + // If shutdown_request with approve=true, mark member as shutdown + if (msgType === 'shutdown_request' && recipient) { + delta.members = [{ name: recipient, status: 'shutdown' }] + } + + return delta +} + +export function extractTeamStateFromMessageContent(messageContent: unknown): TeamStateDelta | null { + const record = unwrapRoleWrappedRecordEnvelope(messageContent) + if (!record) return null + + if (record.role !== 'agent' && record.role !== 'assistant') return null + if (!isObject(record.content) || typeof record.content.type !== 'string') return null + + const blocks = extractToolBlocks(record.content) + if (blocks.length === 0) return null + + let result: TeamStateDelta | null = null + + for (const block of blocks) { + let delta: TeamStateDelta | null = null + + switch (block.name) { + case 'TeamCreate': + delta = processTeamCreate(block.input) + break + case 'TeamDelete': + delta = processTeamDelete() + break + case 'Task': + delta = processTaskToolWithTeam(block.input) + break + case 'TaskCreate': + delta = processTaskCreate(block.input) + break + case 'TaskUpdate': + delta = processTaskUpdate(block.input) + break + case 'SendMessage': + delta = processSendMessage(block.input) + break + } + + if (delta) { + result = result ? mergeDelta(result, delta) : delta + } + } + + return result +} + +function mergeDelta(base: TeamStateDelta, incoming: TeamStateDelta): TeamStateDelta { + // delete action overrides everything + if (incoming._action === 'delete') return incoming + // create action overrides everything + if (incoming._action === 'create') return incoming + + const merged = { ...base } + + if (incoming.members) { + merged.members = [...(merged.members ?? []), ...incoming.members] + } + if (incoming.tasks) { + merged.tasks = [...(merged.tasks ?? []), ...incoming.tasks] + } + if (incoming.messages) { + merged.messages = [...(merged.messages ?? []), ...incoming.messages] + } + if (incoming.updatedAt) { + merged.updatedAt = incoming.updatedAt + } + + return merged +} + +export function applyTeamStateDelta( + existing: TeamState | null | undefined, + delta: TeamStateDelta +): TeamState | null { + if (delta._action === 'delete') return null + + if (delta._action === 'create') { + const { _action: _, ...state } = delta + return state as TeamState + } + + // update: merge into existing + if (!existing) return null + + const updated = { ...existing } + + if (delta.members) { + const memberMap = new Map((updated.members ?? []).map(m => [m.name, m])) + for (const member of delta.members) { + const existing = memberMap.get(member.name) + if (existing) { + memberMap.set(member.name, { ...existing, ...member }) + } else { + memberMap.set(member.name, member) + } + } + updated.members = Array.from(memberMap.values()) + } + + if (delta.tasks) { + const taskMap = new Map((updated.tasks ?? []).map(t => [t.id, t])) + for (const task of delta.tasks) { + const existing = taskMap.get(task.id) + if (existing) { + taskMap.set(task.id, { ...existing, ...task }) + } 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) + } + } + updated.tasks = Array.from(taskMap.values()) + } + + if (delta.messages) { + const msgs = updated.messages ?? [] + updated.messages = [...msgs, ...delta.messages].slice(-50) + } + + if (delta.updatedAt) { + updated.updatedAt = delta.updatedAt + } + + return updated +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 73402684..5fc5c1bb 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -98,6 +98,45 @@ export type TodoItem = z.infer 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() +}) + +export type TeamMember = z.infer + +export const TeamTaskSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + status: z.enum(['pending', 'in_progress', 'completed', 'blocked']).optional(), + owner: z.string().optional() +}) + +export type TeamTask = z.infer + +export const TeamMessageSchema = z.object({ + from: z.string(), + to: z.string(), + summary: z.string(), + type: z.enum(['message', 'broadcast', 'shutdown_request', 'shutdown_response']), + timestamp: z.number() +}) + +export type TeamMessage = 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(), + updatedAt: z.number().optional() +}) + +export type TeamState = z.infer + export const AttachmentMetadataSchema = z.object({ id: z.string(), filename: z.string(), @@ -134,6 +173,7 @@ export const SessionSchema = z.object({ thinking: z.boolean(), thinkingAt: z.number(), todos: TodosSchema.optional(), + teamState: TeamStateSchema.optional(), permissionMode: PermissionModeSchema.optional(), modelMode: ModelModeSchema.optional() }) diff --git a/shared/src/types.ts b/shared/src/types.ts index 665c46bb..60f95018 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -7,6 +7,10 @@ export type { Metadata, Session, SyncEvent, + TeamMember, + TeamMessage, + TeamState, + TeamTask, TodoItem, WorktreeMetadata } from './schemas' diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 3990cc29..569f40b7 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -13,6 +13,7 @@ import { HappyThread } from '@/components/AssistantChat/HappyThread' import { useHappyRuntime } from '@/lib/assistant-runtime' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' import { SessionHeader } from '@/components/SessionHeader' +import { TeamPanel } from '@/components/TeamPanel' import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { useVoiceOptional } from '@/lib/voice-context' @@ -274,6 +275,10 @@ export function SessionChat(props: { onSessionDeleted={props.onBack} /> + {props.session.teamState && ( + + )} + {sessionInactive ? (
diff --git a/web/src/components/TeamPanel.tsx b/web/src/components/TeamPanel.tsx new file mode 100644 index 00000000..da065349 --- /dev/null +++ b/web/src/components/TeamPanel.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' +import type { TeamState } from '@hapi/protocol/types' + +function memberStatusDot(status?: string): string { + if (status === 'active') return 'bg-emerald-500' + if (status === 'shutdown') return 'bg-red-500' + 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 taskStatusIcon(status?: string): string { + if (status === 'completed') return '\u2611' + if (status === 'in_progress') return '\u25b6' + if (status === 'blocked') return '\u26a0' + return '\u2610' +} + +export function TeamPanel(props: { teamState: TeamState }) { + const [expanded, setExpanded] = useState(false) + const { teamState } = props + const members = teamState.members ?? [] + const tasks = teamState.tasks ?? [] + const messages = teamState.messages ?? [] + + const completedTasks = tasks.filter(t => t.status === 'completed').length + const activeMembers = members.filter(m => m.status === 'active').length + + return ( +
+ + + {expanded && ( +
+ {teamState.description && ( +

{teamState.description}

+ )} + + {/* Members */} + {members.length > 0 && ( +
+
Members
+
+ {members.map((member) => ( +
+ + {member.name} + {member.agentType && ( + ({member.agentType}) + )} +
+ ))} +
+
+ )} + + {/* Tasks */} + {tasks.length > 0 && ( +
+
Tasks
+
+ {tasks.map((task, idx) => ( +
+ {taskStatusIcon(task.status)} + {' '} + {task.title} + {task.owner && ( + [{task.owner}] + )} +
+ ))} +
+
+ )} + + {/* Recent Messages */} + {messages.length > 0 && ( +
+
Recent Messages
+
+ {messages.slice(-5).map((msg, idx) => ( +
+ {msg.from} + {' \u2192 '} + {msg.to} + {': '} + {msg.summary} +
+ ))} +
+
+ )} +
+ )} +
+ ) +} diff --git a/web/src/components/ToolCard/icons.tsx b/web/src/components/ToolCard/icons.tsx index 24000ad2..ac62953e 100644 --- a/web/src/components/ToolCard/icons.tsx +++ b/web/src/components/ToolCard/icons.tsx @@ -137,3 +137,24 @@ export function QuestionIcon(props: IconProps) { props ) } + +export function UsersIcon(props: IconProps) { + return createIcon( + <> + + + + + , + props + ) +} + +export function MessageSquareIcon(props: IconProps) { + return createIcon( + <> + + , + props + ) +} diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 3c7e4407..9bf6eeb1 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import type { SessionMetadataSummary } from '@/types/api' import { isObject } from '@hapi/protocol' -import { BulbIcon, ClipboardIcon, EyeIcon, FileDiffIcon, GlobeIcon, PuzzleIcon, QuestionIcon, RocketIcon, SearchIcon, TerminalIcon, WrenchIcon } from '@/components/ToolCard/icons' +import { BulbIcon, ClipboardIcon, EyeIcon, FileDiffIcon, GlobeIcon, MessageSquareIcon, PuzzleIcon, QuestionIcon, RocketIcon, SearchIcon, TerminalIcon, UsersIcon, WrenchIcon } from '@/components/ToolCard/icons' import { basename, resolveDisplayPath } from '@/utils/path' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' @@ -56,6 +56,9 @@ export const knownTools: Record , title: (opts) => { + const name = getInputStringAny(opts.input, ['name']) + const teamName = getInputStringAny(opts.input, ['team_name']) + if (name && teamName) return `Agent: ${name}` const description = getInputStringAny(opts.input, ['description']) return description ?? 'Task' }, @@ -65,6 +68,36 @@ export const knownTools: Record opts.childrenCount === 0 }, + TeamCreate: { + icon: () => , + title: (opts) => { + const teamName = getInputStringAny(opts.input, ['team_name']) + return teamName ? `Team: ${teamName}` : 'Create Team' + }, + subtitle: (opts) => getInputStringAny(opts.input, ['description']) ?? null, + minimal: false + }, + TeamDelete: { + icon: () => , + title: () => 'Delete Team', + minimal: true + }, + SendMessage: { + icon: () => , + title: (opts) => { + const recipient = getInputStringAny(opts.input, ['recipient']) + const msgType = getInputStringAny(opts.input, ['type']) + if (msgType === 'broadcast') return 'Broadcast' + if (msgType === 'shutdown_request') return `Shutdown: ${recipient ?? 'agent'}` + if (msgType === 'shutdown_response') return 'Shutdown Response' + return recipient ? `Message: ${recipient}` : 'Send Message' + }, + subtitle: (opts) => { + const summary = getInputStringAny(opts.input, ['summary']) + return summary ? truncate(summary, 120) : null + }, + minimal: true + }, Bash: { icon: () => , title: (opts) => opts.description ?? 'Terminal', diff --git a/web/src/types/api.ts b/web/src/types/api.ts index fe2c8667..57d720ae 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -14,6 +14,10 @@ export type { Session, SessionSummary, SessionSummaryMetadata, + TeamMember, + TeamMessage, + TeamState, + TeamTask, TodoItem, WorktreeMetadata } from '@hapi/protocol/types' diff --git a/web/vite.config.ts b/web/vite.config.ts index 0e328a04..02bb58cd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -6,6 +6,7 @@ import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const base = process.env.VITE_BASE_URL || '/' +const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006' export default defineConfig({ define: { @@ -16,11 +17,11 @@ export default defineConfig({ allowedHosts: ['hapidev.weishu.me'], proxy: { '/api': { - target: 'http://127.0.0.1:3006', + target: hubTarget, changeOrigin: true }, '/socket.io': { - target: 'http://127.0.0.1:3006', + target: hubTarget, ws: true } }