mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Prevent local team sessions from dropping members and stalling adapters
This bundles the pending desktop/server team-session fixes with the local adapter recovery changes already in the worktree. The team path now keeps teammate membership stable under concurrent spawns, surfaces real teammate identities in the desktop UI, and allows direct interaction with member transcripts. The adapter changes recover automatically when stale thinking signatures invalidate an existing session. Constraint: Team config writes can happen concurrently while multiple reviewers spawn in parallel Constraint: Desktop member views must follow mailbox/transcript semantics rather than hijacking teammate runtime sessions Rejected: Keep relying on config.json alone for member discovery | in-process teammates can be lost after concurrent writes Rejected: Open teammate sessionIds as normal desktop sessions | would attach a second CLI instead of the running teammate Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Preserve locked team-file mutation for any future teammate registration path and keep teammate labels sourced from member names before agent types Tested: bun test src/server/__tests__/teams.test.ts src/server/__tests__/team-watcher.test.ts Tested: cd desktop && bun run test --run src/stores/chatStore.test.ts src/pages/ActiveSession.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Manual end-to-end validation against a live Agent Teams run in the desktop app
This commit is contained in:
parent
9eb3be4b11
commit
23b31b397a
1
.gitignore
vendored
1
.gitignore
vendored
@ -40,6 +40,7 @@ docs/superpowers/
|
||||
|
||||
# Debug / temp screenshots (root dir only)
|
||||
/*.png
|
||||
bug_attachments/
|
||||
|
||||
# Private keys & credentials
|
||||
*.pem
|
||||
|
||||
@ -868,8 +868,33 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
|
||||
case 'error':
|
||||
runtime.state = 'idle'
|
||||
runtime.verb = undefined
|
||||
// 如果 streaming card 存在就把错误渲染到卡上,否则 fallback 到 sendText
|
||||
if (streamingCards.has(chatId)) {
|
||||
// Auto-recover from stale thinking block signatures by creating a fresh session.
|
||||
if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) {
|
||||
// Abort any in-flight streaming card first
|
||||
if (streamingCards.has(chatId)) {
|
||||
const card = streamingCards.get(chatId)!
|
||||
streamingCards.delete(chatId)
|
||||
void card.abort(new Error('session reset')).catch(() => {})
|
||||
}
|
||||
const stored = sessionStore.get(chatId)
|
||||
const workDir = stored?.workDir || config.defaultProjectDir
|
||||
if (workDir) {
|
||||
await sendText(chatId, '⚠️ 会话上下文已失效,正在自动重建...')
|
||||
bridge.resetSession(chatId)
|
||||
sessionStore.delete(chatId)
|
||||
imageWatchers.delete(chatId)
|
||||
uploadedImageKeys.delete(chatId)
|
||||
runtimeStates.delete(chatId)
|
||||
const ok = await createSessionForChat(chatId, workDir)
|
||||
if (ok) {
|
||||
await sendText(chatId, '✅ 已重建会话,请重新发送消息。')
|
||||
} else {
|
||||
await sendText(chatId, '❌ 重建会话失败,请发送 /new 手动新建。')
|
||||
}
|
||||
} else {
|
||||
await sendText(chatId, '⚠️ 会话上下文已失效,请发送 /new 新建会话。')
|
||||
}
|
||||
} else if (streamingCards.has(chatId)) {
|
||||
await abortStreamingCard(chatId, new Error(msg.message ?? 'unknown error'))
|
||||
} else {
|
||||
await sendText(chatId, `❌ ${msg.message}`)
|
||||
|
||||
@ -237,6 +237,11 @@ async function ensureSession(chatId: string): Promise<boolean> {
|
||||
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
|
||||
const numericChatId = Number(chatId)
|
||||
try {
|
||||
// Always tear down any stale WS connection before creating a new session.
|
||||
// Without this, bridge.connectSession() below would short-circuit when an
|
||||
// old OPEN connection still exists, leaving messages routed to the old session.
|
||||
bridge.resetSession(chatId)
|
||||
|
||||
const sessionId = await httpClient.createSession(workDir)
|
||||
sessionStore.set(chatId, sessionId, workDir)
|
||||
bridge.connectSession(chatId, sessionId)
|
||||
@ -435,7 +440,28 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
|
||||
case 'error':
|
||||
runtime.state = 'idle'
|
||||
runtime.verb = undefined
|
||||
await bot.api.sendMessage(numericChatId, `❌ ${msg.message}`)
|
||||
// Auto-recover from stale thinking block signatures by creating a fresh session.
|
||||
// This happens when the API key or provider changed since the session was created.
|
||||
if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) {
|
||||
const stored = sessionStore.get(chatId)
|
||||
const workDir = stored?.workDir || config.defaultProjectDir
|
||||
if (workDir) {
|
||||
await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,正在自动重建...')
|
||||
clearTransientChatState(chatId)
|
||||
bridge.resetSession(chatId)
|
||||
sessionStore.delete(chatId)
|
||||
const ok = await createSessionForChat(chatId, workDir)
|
||||
if (ok) {
|
||||
await bot.api.sendMessage(numericChatId, '✅ 已重建会话,请重新发送消息。')
|
||||
} else {
|
||||
await bot.api.sendMessage(numericChatId, '❌ 重建会话失败,请发送 /new 手动新建。')
|
||||
}
|
||||
} else {
|
||||
await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,请发送 /new 新建会话。')
|
||||
}
|
||||
} else {
|
||||
await bot.api.sendMessage(numericChatId, `❌ ${msg.message}`)
|
||||
}
|
||||
break
|
||||
|
||||
case 'system_notification':
|
||||
|
||||
@ -6,7 +6,6 @@ import '@testing-library/jest-dom'
|
||||
import { EmptySession } from '../pages/EmptySession'
|
||||
import { ActiveSession } from '../pages/ActiveSession'
|
||||
import { AgentTeams } from '../pages/AgentTeams'
|
||||
import { AgentTranscript } from '../pages/AgentTranscript'
|
||||
import { ScheduledTasks } from '../pages/ScheduledTasks'
|
||||
import { ToolInspection } from '../pages/ToolInspection'
|
||||
|
||||
@ -107,13 +106,6 @@ describe('Content-only pages render without errors', () => {
|
||||
expect(container.innerHTML).toContain('groups')
|
||||
})
|
||||
|
||||
it('AgentTranscript renders transcript content', () => {
|
||||
const { container } = render(<AgentTranscript />)
|
||||
expect(container.innerHTML).toContain('Frontend Dev')
|
||||
expect(container.innerHTML).toContain('Back to Leader')
|
||||
expect(container.innerHTML).toContain('Navigation.tsx')
|
||||
})
|
||||
|
||||
it('ScheduledTasks renders (store-connected)', async () => {
|
||||
const { container } = render(<ScheduledTasks />)
|
||||
await screen.findByText('Scheduled tasks')
|
||||
@ -160,7 +152,7 @@ describe('AppShell layout renders chrome', () => {
|
||||
|
||||
describe('Design system compliance', () => {
|
||||
it('Pages use Material Symbols Outlined icons', () => {
|
||||
const pages = [EmptySession, AgentTeams, AgentTranscript, ToolInspection]
|
||||
const pages = [EmptySession, AgentTeams, ToolInspection]
|
||||
for (const Page of pages) {
|
||||
const { container, unmount } = render(<Page />)
|
||||
const icons = container.querySelectorAll('.material-symbols-outlined')
|
||||
@ -193,9 +185,4 @@ describe('Mock data integration', () => {
|
||||
expect(container.innerHTML).toContain('Backend Dev')
|
||||
expect(container.innerHTML).toContain('Tester')
|
||||
})
|
||||
|
||||
it('AgentTranscript shows transcript content', () => {
|
||||
const { container } = render(<AgentTranscript />)
|
||||
expect(container.innerHTML).toContain('Navigation.tsx')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,21 @@
|
||||
import { api } from './client'
|
||||
import type { TeamSummary, TeamDetail, TranscriptMessage } from '../types/team'
|
||||
import type { TeamSummary, TeamDetail } from '../types/team'
|
||||
|
||||
type TeamsResponse = { teams: TeamSummary[] }
|
||||
|
||||
type TranscriptMessage = {
|
||||
id: string
|
||||
type: string
|
||||
content: unknown
|
||||
timestamp: string
|
||||
model?: string
|
||||
parentToolUseId?: string
|
||||
}
|
||||
|
||||
type TranscriptResponse = { messages: TranscriptMessage[] }
|
||||
|
||||
export type { TranscriptMessage }
|
||||
|
||||
export const teamsApi = {
|
||||
list() {
|
||||
return api.get<TeamsResponse>('/api/teams')
|
||||
@ -19,6 +31,13 @@ export const teamsApi = {
|
||||
)
|
||||
},
|
||||
|
||||
sendMemberMessage(teamName: string, agentId: string, content: string) {
|
||||
return api.post<{ ok: true }>(
|
||||
`/api/teams/${encodeURIComponent(teamName)}/members/${encodeURIComponent(agentId)}/messages`,
|
||||
{ content },
|
||||
)
|
||||
},
|
||||
|
||||
delete(name: string) {
|
||||
return api.delete<{ ok: true }>(`/api/teams/${encodeURIComponent(name)}`)
|
||||
},
|
||||
|
||||
@ -3,6 +3,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
|
||||
import { ModelSelector } from '../controls/ModelSelector'
|
||||
@ -53,12 +54,14 @@ export function ChatInput() {
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const slashCommands = sessionState?.slashCommands ?? []
|
||||
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
|
||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
||||
const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false)
|
||||
|
||||
const isMemberSession = !!memberInfo
|
||||
const isActive = chatState !== 'idle'
|
||||
const isWorkspaceMissing = activeSession?.workDirExists === false
|
||||
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || attachments.length > 0)
|
||||
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
@ -69,8 +72,20 @@ export function ChatInput() {
|
||||
setGitInfo(null)
|
||||
return
|
||||
}
|
||||
if (isMemberSession) {
|
||||
setGitInfo(null)
|
||||
return
|
||||
}
|
||||
sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null))
|
||||
}, [activeTabId])
|
||||
}, [activeTabId, isMemberSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMemberSession) return
|
||||
setAttachments([])
|
||||
setPlusMenuOpen(false)
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(false)
|
||||
}, [isMemberSession, activeTabId])
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current
|
||||
@ -191,6 +206,10 @@ export function ChatInput() {
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = event.target.value
|
||||
if (isMemberSession) {
|
||||
setInput(value)
|
||||
return
|
||||
}
|
||||
const cursorPos = event.target.selectionStart ?? value.length
|
||||
setInput(value)
|
||||
detectSlashTrigger(value, cursorPos)
|
||||
@ -212,7 +231,7 @@ export function ChatInput() {
|
||||
|
||||
const handleSubmit = () => {
|
||||
const text = input.trim()
|
||||
if ((!text && attachments.length === 0) || isWorkspaceMissing) return
|
||||
if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return
|
||||
|
||||
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
||||
type: attachment.type,
|
||||
@ -224,7 +243,9 @@ export function ChatInput() {
|
||||
sendMessage(activeTabId!, text, attachmentPayload)
|
||||
setInput('')
|
||||
setAttachments([])
|
||||
setPlusMenuOpen(false)
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
@ -280,6 +301,7 @@ export function ChatInput() {
|
||||
}
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent) => {
|
||||
if (isMemberSession) return
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
@ -315,6 +337,7 @@ export function ChatInput() {
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (isMemberSession) return
|
||||
const files = event.target.files
|
||||
if (!files) return
|
||||
|
||||
@ -343,6 +366,7 @@ export function ChatInput() {
|
||||
|
||||
const handleDrop = (event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
if (isMemberSession) return
|
||||
const files = event.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
|
||||
@ -355,6 +379,7 @@ export function ChatInput() {
|
||||
}
|
||||
|
||||
const insertSlashCommand = () => {
|
||||
if (isMemberSession) return
|
||||
const el = textareaRef.current
|
||||
const cursorPos = el?.selectionStart ?? input.length
|
||||
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
|
||||
@ -377,7 +402,7 @@ export function ChatInput() {
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{fileSearchOpen && (
|
||||
{!isMemberSession && fileSearchOpen && (
|
||||
<FileSearchMenu
|
||||
ref={fileSearchRef}
|
||||
cwd={gitInfo?.workDir || activeSession?.workDir || ''}
|
||||
@ -400,7 +425,7 @@ export function ChatInput() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{slashMenuOpen && filteredCommands.length > 0 && (
|
||||
{!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
|
||||
<div
|
||||
ref={slashMenuRef}
|
||||
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
|
||||
@ -457,7 +482,9 @@ export function ChatInput() {
|
||||
placeholder={
|
||||
isWorkspaceMissing
|
||||
? t('chat.placeholderMissing')
|
||||
: t('chat.placeholder')
|
||||
: isMemberSession
|
||||
? t('teams.memberPlaceholder')
|
||||
: t('chat.placeholder')
|
||||
}
|
||||
disabled={isWorkspaceMissing}
|
||||
rows={1}
|
||||
@ -466,55 +493,59 @@ export function ChatInput() {
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[#dac1ba]/10 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div ref={plusMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setPlusMenuOpen((value) => !value)}
|
||||
aria-label="Open composer tools"
|
||||
className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[#F4F4F0]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
</button>
|
||||
{!isMemberSession && (
|
||||
<>
|
||||
<div ref={plusMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setPlusMenuOpen((value) => !value)}
|
||||
aria-label="Open composer tools"
|
||||
className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[#F4F4F0]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||
</button>
|
||||
|
||||
{plusMenuOpen && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[#dac1ba]/20 bg-white py-1 shadow-[0_18px_48px_rgba(27,28,26,0.12)]">
|
||||
<button
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click()
|
||||
setPlusMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.addFiles')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={insertSlashCommand}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
|
||||
>
|
||||
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.slashCommands')}</span>
|
||||
</button>
|
||||
{plusMenuOpen && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[#dac1ba]/20 bg-white py-1 shadow-[0_18px_48px_rgba(27,28,26,0.12)]">
|
||||
<button
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click()
|
||||
setPlusMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.addFiles')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={insertSlashCommand}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
|
||||
>
|
||||
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.slashCommands')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PermissionModeSelector />
|
||||
<PermissionModeSelector />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector />
|
||||
{!isMemberSession && <ModelSelector />}
|
||||
<button
|
||||
onClick={isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
|
||||
disabled={!isActive && !canSubmit}
|
||||
title={isActive ? t('chat.stopTitle') : undefined}
|
||||
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
|
||||
disabled={!isMemberSession && isActive ? false : !canSubmit}
|
||||
title={!isMemberSession && isActive ? t('chat.stopTitle') : undefined}
|
||||
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30 ${
|
||||
isActive ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
|
||||
!isMemberSession && isActive ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{isActive ? 'stop' : 'arrow_forward'}
|
||||
{!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
|
||||
</span>
|
||||
{isActive ? t('common.stop') : t('common.run')}
|
||||
{!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -522,33 +553,33 @@ export function ChatInput() {
|
||||
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
|
||||
|
||||
<div className="mt-3 px-1">
|
||||
{hasMessages ? (
|
||||
<ProjectContextChip
|
||||
workDir={gitInfo?.workDir || activeSession?.workDir}
|
||||
repoName={gitInfo?.repoName || null}
|
||||
branch={gitInfo?.branch || null}
|
||||
/>
|
||||
) : (
|
||||
<DirectoryPicker
|
||||
value={gitInfo?.workDir || activeSession?.workDir || ''}
|
||||
onChange={async (newWorkDir) => {
|
||||
if (!activeTabId) return
|
||||
const oldId = activeTabId
|
||||
const { deleteSession, createSession } = useSessionStore.getState()
|
||||
const { replaceTabSession } = useTabStore.getState()
|
||||
const { disconnectSession, connectToSession } = useChatStore.getState()
|
||||
// Create new session first, then swap tab in-place
|
||||
const newId = await createSession(newWorkDir)
|
||||
disconnectSession(oldId)
|
||||
replaceTabSession(oldId, newId)
|
||||
connectToSession(newId)
|
||||
// Clean up old session in background
|
||||
deleteSession(oldId).catch(() => {})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isMemberSession && (
|
||||
<div className="mt-3 px-1">
|
||||
{hasMessages ? (
|
||||
<ProjectContextChip
|
||||
workDir={gitInfo?.workDir || activeSession?.workDir}
|
||||
repoName={gitInfo?.repoName || null}
|
||||
branch={gitInfo?.branch || null}
|
||||
/>
|
||||
) : (
|
||||
<DirectoryPicker
|
||||
value={gitInfo?.workDir || activeSession?.workDir || ''}
|
||||
onChange={async (newWorkDir) => {
|
||||
if (!activeTabId) return
|
||||
const oldId = activeTabId
|
||||
const { deleteSession, createSession } = useSessionStore.getState()
|
||||
const { replaceTabSession } = useTabStore.getState()
|
||||
const { disconnectSession, connectToSession } = useChatStore.getState()
|
||||
const newId = await createSession(newWorkDir)
|
||||
disconnectSession(oldId)
|
||||
replaceTabSession(oldId, newId)
|
||||
connectToSession(newId)
|
||||
deleteSession(oldId).catch(() => {})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -160,7 +160,7 @@ export function MessageList() {
|
||||
)
|
||||
}
|
||||
|
||||
const MessageBlock = memo(function MessageBlock({
|
||||
export const MessageBlock = memo(function MessageBlock({
|
||||
message,
|
||||
activeThinkingId,
|
||||
agentTaskNotifications,
|
||||
|
||||
@ -5,6 +5,7 @@ import { Modal } from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { AGENT_LIFECYCLE_TYPES } from '../../types/team'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
@ -286,7 +287,7 @@ function AgentCallCard({
|
||||
? getAgentErrorSummary(result.content)
|
||||
: ''
|
||||
const fullOutputText =
|
||||
result && !result.isError && !isLaunchResult
|
||||
result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content)
|
||||
? extractTextContent(result.content).trim()
|
||||
: ''
|
||||
const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskSummary : '')
|
||||
@ -306,7 +307,12 @@ function AgentCallCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!expanded && recentToolCalls.length > 0 && (
|
||||
{!expanded && outputSummary && (
|
||||
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{outputSummary}
|
||||
</div>
|
||||
)}
|
||||
{!expanded && !outputSummary && recentToolCalls.length > 0 && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{recentToolCalls.map((recentToolCall) => (
|
||||
<div
|
||||
@ -318,16 +324,11 @@ function AgentCallCard({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!expanded && !recentToolCalls.length && errorText && (
|
||||
{!expanded && !outputSummary && !recentToolCalls.length && errorText && (
|
||||
<div className="mt-1 truncate text-[11px] text-[var(--color-error)]">
|
||||
{errorText}
|
||||
</div>
|
||||
)}
|
||||
{!expanded && !recentToolCalls.length && !errorText && outputSummary && (
|
||||
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{outputSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{outputSummary && (
|
||||
<button
|
||||
@ -565,6 +566,27 @@ function isAgentLaunchResult(content: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if agent result content is a lifecycle notification (shutdown, terminated, etc.)
|
||||
* rather than actual agent output. These should not be shown to the user as results.
|
||||
*/
|
||||
function isAgentLifecycleResult(content: unknown): boolean {
|
||||
const text = extractTextContent(content).trim()
|
||||
if (!text) return false
|
||||
// Detect JSON lifecycle messages: shutdown_approved, shutdown_rejected, teammate_terminated
|
||||
if (text.startsWith('{') && text.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, not a lifecycle message
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) {
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { EmptySession } from '../../pages/EmptySession'
|
||||
import { ActiveSession } from '../../pages/ActiveSession'
|
||||
import { ScheduledTasks } from '../../pages/ScheduledTasks'
|
||||
import { Settings } from '../../pages/Settings'
|
||||
import { AgentTranscript } from '../../pages/AgentTranscript'
|
||||
|
||||
export function ContentRouter() {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const activeTabType = useTabStore((s) => s.tabs.find((t) => t.sessionId === s.activeTabId)?.type)
|
||||
const viewingAgentId = useTeamStore((s) => s.viewingAgentId)
|
||||
|
||||
// No tabs open — show empty session
|
||||
if (!activeTabId || !activeTabType) {
|
||||
@ -25,10 +22,6 @@ export function ContentRouter() {
|
||||
return <ScheduledTasks />
|
||||
}
|
||||
|
||||
// Session tab — show agent transcript or active session
|
||||
if (viewingAgentId) {
|
||||
return <AgentTranscript />
|
||||
}
|
||||
|
||||
// Session tab — ActiveSession handles both regular and member sessions
|
||||
return <ActiveSession />
|
||||
}
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function BackToLeader() {
|
||||
const setViewingAgent = useTeamStore((s) => s.setViewingAgent)
|
||||
const t = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setViewingAgent(null)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[var(--color-text-accent)] hover:underline transition-colors"
|
||||
>
|
||||
{t('teams.backToLeader')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import type { TeamMember, AgentColor } from '../../types/team'
|
||||
|
||||
const COLOR_MAP: Record<AgentColor, string> = {
|
||||
red: 'var(--color-agent-red)',
|
||||
blue: 'var(--color-agent-blue)',
|
||||
green: 'var(--color-agent-green)',
|
||||
yellow: 'var(--color-agent-yellow)',
|
||||
purple: 'var(--color-agent-purple)',
|
||||
orange: 'var(--color-agent-orange)',
|
||||
pink: 'var(--color-agent-pink)',
|
||||
cyan: 'var(--color-agent-cyan)',
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { dotClass: string; color: string }> = {
|
||||
running: { dotClass: 'animate-pulse-dot', color: 'var(--color-warning)' },
|
||||
completed: { dotClass: '', color: 'var(--color-success)' },
|
||||
error: { dotClass: '', color: 'var(--color-error)' },
|
||||
idle: { dotClass: '', color: 'var(--color-text-tertiary)' },
|
||||
}
|
||||
|
||||
export function MemberTag({ member }: { member: TeamMember }) {
|
||||
const { setViewingAgent, viewingAgentId, memberColors } = useTeamStore()
|
||||
const isViewing = viewingAgentId === member.agentId
|
||||
const color = member.color || memberColors.get(member.agentId) || 'blue'
|
||||
const agentColor = COLOR_MAP[color]
|
||||
const statusInfo = STATUS_STYLES[member.status] || STATUS_STYLES.idle!
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setViewingAgent(isViewing ? null : member.agentId)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-[var(--radius-md)] text-xs font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: isViewing ? `color-mix(in srgb, ${agentColor} 15%, transparent)` : 'var(--color-surface-selected)',
|
||||
border: isViewing ? `2px solid ${agentColor}` : '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dotClass}`}
|
||||
style={{ backgroundColor: statusInfo.color }}
|
||||
/>
|
||||
<span className="text-[var(--color-text-primary)]">{member.role || member.agentId}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -1,31 +1,147 @@
|
||||
import { useState } from 'react'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { MemberTag } from './MemberTag'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TeamMember } from '../../types/team'
|
||||
|
||||
const memberStatusConfig = {
|
||||
running: {
|
||||
icon: 'pending',
|
||||
color: 'var(--color-warning)',
|
||||
pulse: true,
|
||||
},
|
||||
idle: {
|
||||
icon: 'radio_button_unchecked',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
pulse: false,
|
||||
},
|
||||
completed: {
|
||||
icon: 'check_circle',
|
||||
color: 'var(--color-success)',
|
||||
pulse: false,
|
||||
},
|
||||
error: {
|
||||
icon: 'error',
|
||||
color: 'var(--color-error)',
|
||||
pulse: false,
|
||||
},
|
||||
} as const
|
||||
|
||||
export function TeamStatusBar() {
|
||||
const t = useTranslation()
|
||||
const { activeTeam } = useTeamStore()
|
||||
const { activeTeam, openMemberSession } = useTeamStore()
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
|
||||
if (!activeTeam) return null
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-info)]">
|
||||
{/* Team header */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
|
||||
{t('teams.team')} {activeTeam.name}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
({activeTeam.members.length} {t('teams.members')})
|
||||
</span>
|
||||
</div>
|
||||
// Filter out leader — main window is already the leader's view
|
||||
const members = activeTeam.members.filter(
|
||||
(m) => !activeTeam.leadAgentId || m.agentId !== activeTeam.leadAgentId,
|
||||
)
|
||||
const runningCount = members.filter((m) => m.status === 'running').length
|
||||
const completedCount = members.filter((m) => m.status === 'completed').length
|
||||
const totalCount = members.length
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
|
||||
const allDone = runningCount === 0 && totalCount > 0
|
||||
|
||||
{/* Member tags */}
|
||||
<div className="px-4 pb-2 flex flex-wrap gap-2">
|
||||
{activeTeam.members.map((member) => (
|
||||
<MemberTag key={member.agentId} member={member} />
|
||||
))}
|
||||
return (
|
||||
<div className="shrink-0 px-8">
|
||||
<div className="mx-auto max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden mb-2">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--color-surface-container-low)] transition-colors bg-[var(--color-surface-container)]"
|
||||
>
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-brand)]/10">
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">groups</span>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
|
||||
{t('teams.team')} {activeTeam.name}
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: allDone ? 'var(--color-success)' : 'var(--color-brand)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
|
||||
{runningCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-warning)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
|
||||
{runningCount} {t('teams.running')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
expand_less
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded member list */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-2 pt-1 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto border-t border-[var(--color-outline-variant)]/20">
|
||||
{members.map((member) => (
|
||||
<MemberRow key={member.agentId} member={member} onView={() => openMemberSession(member)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberRow({ member, onView }: { member: TeamMember; onView: () => void }) {
|
||||
const config = memberStatusConfig[member.status] || memberStatusConfig.idle
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onView}
|
||||
className="w-full flex items-center gap-2 py-1.5 px-1 rounded-md text-left hover:bg-[var(--color-surface-container-low)] transition-colors group"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] shrink-0 ${config.pulse ? 'animate-pulse-dot' : ''}`}
|
||||
style={{ color: config.color, fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
{config.icon}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<span className={`text-xs ${
|
||||
member.status === 'completed'
|
||||
? 'text-[var(--color-text-tertiary)]'
|
||||
: 'text-[var(--color-text-primary)]'
|
||||
}`}>
|
||||
{member.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{member.status === 'running' && member.currentTask && (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
|
||||
<span className="text-[10px] text-[var(--color-warning)] truncate">
|
||||
{member.currentTask}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
open_in_new
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function TranscriptView() {
|
||||
const t = useTranslation()
|
||||
const { agentTranscript, viewingAgentId, activeTeam } = useTeamStore()
|
||||
const member = activeTeam?.members.find((m) => m.agentId === viewingAgentId)
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<div className="max-w-[720px] mx-auto">
|
||||
{/* Viewing divider */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 h-px bg-[var(--color-border)]" />
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('teams.viewing')} {member?.role || viewingAgentId} {t('teams.transcript')}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-[var(--color-border)]" />
|
||||
</div>
|
||||
|
||||
{/* Transcript messages */}
|
||||
{agentTranscript.length === 0 ? (
|
||||
<div className="text-center text-sm text-[var(--color-text-tertiary)] py-8">
|
||||
{t('teams.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
agentTranscript.map((msg) => (
|
||||
<div key={msg.id} className="mb-3 text-sm text-[var(--color-text-primary)]">
|
||||
<pre className="whitespace-pre-wrap break-words font-[var(--font-mono)] text-xs bg-[var(--color-surface-info)] rounded-[var(--radius-md)] px-3 py-2">
|
||||
{typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,7 @@ export const en = {
|
||||
'common.delete': 'Delete',
|
||||
'common.add': 'Add',
|
||||
'common.run': 'Run',
|
||||
'common.send': 'Send',
|
||||
'common.stop': 'Stop',
|
||||
'common.rename': 'Rename',
|
||||
'common.retry': 'Retry',
|
||||
@ -489,6 +490,12 @@ export const en = {
|
||||
'teams.noMessages': 'No messages yet',
|
||||
'teams.team': 'Team:',
|
||||
'teams.members': 'members',
|
||||
'teams.working': 'Working...',
|
||||
'teams.thinking': 'Thinking...',
|
||||
'teams.running': 'running',
|
||||
'teams.leader': 'team-lead',
|
||||
'teams.memberPlaceholder': 'Message this teammate directly...',
|
||||
'teams.memberSessionHint': 'Messages here go straight to this teammate while you watch its transcript update.',
|
||||
|
||||
// ─── Scheduled Tasks Pages ──────────────────────────────────────
|
||||
'scheduledPage.title': 'Scheduled tasks',
|
||||
|
||||
@ -7,6 +7,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'common.delete': '删除',
|
||||
'common.add': '添加',
|
||||
'common.run': '运行',
|
||||
'common.send': '发送',
|
||||
'common.stop': '停止',
|
||||
'common.rename': '重命名',
|
||||
'common.retry': '重试',
|
||||
@ -491,6 +492,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'teams.noMessages': '暂无消息',
|
||||
'teams.team': '团队:',
|
||||
'teams.members': '名成员',
|
||||
'teams.working': '正在工作中...',
|
||||
'teams.thinking': '正在思考中...',
|
||||
'teams.running': '运行中',
|
||||
'teams.leader': '主控',
|
||||
'teams.memberPlaceholder': '直接给这个 Agent 发消息...',
|
||||
'teams.memberSessionHint': '这里发送的消息会直接投递给该 Agent,同时继续刷新它的 transcript。',
|
||||
|
||||
// ─── Scheduled Tasks Pages ──────────────────────────────────────
|
||||
'scheduledPage.title': '定时任务',
|
||||
|
||||
@ -24,12 +24,14 @@ import { useChatStore } from '../stores/chatStore'
|
||||
import { useCLITaskStore } from '../stores/cliTaskStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useTeamStore } from '../stores/teamStore'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null })
|
||||
})
|
||||
|
||||
describe('ActiveSession task polling', () => {
|
||||
@ -102,4 +104,76 @@ describe('ActiveSession task polling', () => {
|
||||
unmount()
|
||||
useCLITaskStore.setState(originalCliTaskState)
|
||||
})
|
||||
|
||||
it('keeps member sessions interactive and skips leader task polling', () => {
|
||||
const memberSessionId = 'team-member:security-reviewer@test-team'
|
||||
const originalCliTaskState = useCLITaskStore.getState()
|
||||
const fetchSessionTasks = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
useCLITaskStore.setState({
|
||||
sessionId: null,
|
||||
tasks: [],
|
||||
fetchSessionTasks,
|
||||
})
|
||||
|
||||
useTeamStore.setState({
|
||||
teams: [],
|
||||
activeTeam: {
|
||||
name: 'test-team',
|
||||
leadAgentId: 'team-lead@test-team',
|
||||
leadSessionId: 'leader-session',
|
||||
members: [
|
||||
{
|
||||
agentId: 'team-lead@test-team',
|
||||
role: 'team-lead',
|
||||
status: 'running',
|
||||
sessionId: 'leader-session',
|
||||
},
|
||||
{
|
||||
agentId: 'security-reviewer@test-team',
|
||||
role: 'security-reviewer',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
},
|
||||
memberColors: new Map(),
|
||||
error: null,
|
||||
})
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }],
|
||||
activeTabId: memberSessionId,
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[memberSessionId]: {
|
||||
messages: [],
|
||||
chatState: 'thinking',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { queryByTestId, unmount } = render(<ActiveSession />)
|
||||
|
||||
expect(queryByTestId('chat-input')).toBeInTheDocument()
|
||||
expect(queryByTestId('session-task-bar')).not.toBeInTheDocument()
|
||||
expect(fetchSessionTasks).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
useCLITaskStore.setState(originalCliTaskState)
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@ import { useTabStore } from '../stores/tabStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useCLITaskStore } from '../stores/cliTaskStore'
|
||||
import { useTeamStore } from '../stores/teamStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { MessageList } from '../components/chat/MessageList'
|
||||
import { ChatInput } from '../components/chat/ChatInput'
|
||||
@ -23,15 +24,18 @@ export function ActiveSession() {
|
||||
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
|
||||
|
||||
const session = sessions.find((s) => s.id === activeTabId)
|
||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||
const activeTeam = useTeamStore((s) => s.activeTeam)
|
||||
const isMemberSession = !!memberInfo
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabId) {
|
||||
if (activeTabId && !isMemberSession) {
|
||||
connectToSession(activeTabId)
|
||||
}
|
||||
}, [activeTabId, connectToSession])
|
||||
}, [activeTabId, isMemberSession, connectToSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return
|
||||
if (!activeTabId || isMemberSession) return
|
||||
|
||||
const shouldPollTasks =
|
||||
chatState !== 'idle' ||
|
||||
@ -48,6 +52,7 @@ export function ActiveSession() {
|
||||
return () => clearInterval(timer)
|
||||
}, [
|
||||
activeTabId,
|
||||
isMemberSession,
|
||||
chatState,
|
||||
trackedTaskSessionId,
|
||||
hasIncompleteTasks,
|
||||
@ -75,76 +80,130 @@ export function ActiveSession() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
|
||||
{isMemberSession && (
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
|
||||
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{memberInfo?.status === 'running' && (
|
||||
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
|
||||
)}
|
||||
{memberInfo?.status === 'completed' && (
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{memberInfo?.role}
|
||||
</span>
|
||||
{activeTeam && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
@ {activeTeam.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('teams.memberSessionHint')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTeam?.leadSessionId) {
|
||||
useTabStore.getState().openTab(
|
||||
activeTeam.leadSessionId,
|
||||
t('teams.leader'),
|
||||
'session',
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={!activeTeam?.leadSessionId}
|
||||
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
|
||||
{t('teams.backToLeader')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
/* Welcome hero — same look as EmptySession */
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px] shadow-[0_2px_12px_rgba(0,0,0,0.06)]" />
|
||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: "'Manrope', sans-serif" }}>
|
||||
{t('empty.title')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{t('empty.subtitle')}
|
||||
</p>
|
||||
{isMemberSession ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<p className="text-[var(--color-text-secondary)]">
|
||||
{memberInfo?.status === 'running'
|
||||
? `${memberInfo.role} ${t('teams.working')}`
|
||||
: t('teams.noMessages')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px] shadow-[0_2px_12px_rgba(0,0,0,0.06)]" />
|
||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: "'Manrope', sans-serif" }}>
|
||||
{t('empty.title')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{t('empty.subtitle')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Session info header */}
|
||||
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
|
||||
{session?.title || t('session.untitled')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
|
||||
{isActive && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
|
||||
{t('session.active')}
|
||||
</span>
|
||||
)}
|
||||
{totalTokens > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{totalTokens.toLocaleString()} t</span>
|
||||
</>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
|
||||
</>
|
||||
)}
|
||||
{session?.messageCount !== undefined && session.messageCount > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.messages', { count: session.messageCount })}</span>
|
||||
</>
|
||||
{!isMemberSession && (
|
||||
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
|
||||
{session?.title || t('session.untitled')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
|
||||
{isActive && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
|
||||
{t('session.active')}
|
||||
</span>
|
||||
)}
|
||||
{totalTokens > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{totalTokens.toLocaleString()} t</span>
|
||||
</>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
|
||||
</>
|
||||
)}
|
||||
{session?.messageCount !== undefined && session.messageCount > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.messages', { count: session.messageCount })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{session?.workDirExists === false && (
|
||||
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span className="truncate">
|
||||
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{session?.workDirExists === false && (
|
||||
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span className="truncate">
|
||||
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message stream */}
|
||||
<MessageList />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Session task bar — sticky at bottom */}
|
||||
<SessionTaskBar />
|
||||
{!isMemberSession && <SessionTaskBar />}
|
||||
|
||||
{/* Team status bar */}
|
||||
<TeamStatusBar />
|
||||
|
||||
{/* Chat input */}
|
||||
<ChatInput />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,189 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTeamStore } from '../stores/teamStore'
|
||||
import { mockTranscript } from '../mocks/data'
|
||||
|
||||
function Icon({ name, className = '', filled = false }: { name: string; className?: string; filled?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`material-symbols-outlined ${className}`}
|
||||
style={filled ? { fontVariationSettings: "'FILL' 1" } : undefined}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentTranscript() {
|
||||
const { viewingAgentId, activeTeam, agentTranscript, setViewingAgent, fetchMemberTranscript } = useTeamStore()
|
||||
|
||||
const viewingMember = activeTeam?.members.find((m) => m.agentId === viewingAgentId)
|
||||
const agentName = viewingMember?.role ?? mockTranscript.agentName
|
||||
|
||||
useEffect(() => {
|
||||
if (viewingAgentId && activeTeam) {
|
||||
fetchMemberTranscript(activeTeam.name, viewingAgentId)
|
||||
}
|
||||
}, [viewingAgentId, activeTeam, fetchMemberTranscript])
|
||||
|
||||
// Use real transcript if available, fall back to mock
|
||||
const messages = agentTranscript.length > 0
|
||||
? agentTranscript.map((msg, i) => ({
|
||||
id: msg.id || `t-${i}`,
|
||||
role: 'agent' as const,
|
||||
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
||||
timestamp: msg.timestamp,
|
||||
}))
|
||||
: mockTranscript.messages
|
||||
|
||||
const teamMembers = activeTeam
|
||||
? activeTeam.members.map((m) => ({
|
||||
id: m.agentId,
|
||||
role: m.role,
|
||||
active: m.agentId === viewingAgentId,
|
||||
}))
|
||||
: mockTranscript.teamBar
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface selection:bg-primary-fixed">
|
||||
{/* Context Banner */}
|
||||
<div className="sticky top-0 z-30 bg-primary-container/10 backdrop-blur-md px-6 py-2 border-b border-primary/10 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-sm font-medium text-primary">
|
||||
Viewing: {agentName} transcript
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setViewingAgent(null)}
|
||||
className="text-xs font-bold text-primary flex items-center gap-1 hover:underline cursor-pointer"
|
||||
>
|
||||
<Icon name="arrow_back" className="text-xs" />
|
||||
Back to Leader
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Teammate Transcript Stream */}
|
||||
<div className="flex-1 overflow-y-auto max-w-4xl mx-auto px-6 py-8 space-y-8 pb-24">
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'agent') {
|
||||
return (
|
||||
<div key={msg.id} className="flex gap-4 items-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-surface-container-high shrink-0 flex items-center justify-center">
|
||||
<Icon name="smart_toy" className="text-secondary" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-on-surface">{agentName}</span>
|
||||
<span className="text-[10px] text-outline font-mono">{msg.timestamp}</span>
|
||||
</div>
|
||||
<div className="p-5 bg-surface-container-lowest rounded-xl border border-outline-variant/15 shadow-sm">
|
||||
<p className="text-on-surface leading-relaxed text-sm">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
return (
|
||||
<div key={msg.id} className="flex gap-4 items-start">
|
||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
||||
<Icon name="terminal" className="text-outline" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between text-[10px] uppercase tracking-widest text-outline font-bold">
|
||||
<span>Tool Call: {msg.toolName?.toLowerCase()}</span>
|
||||
<span className="bg-tertiary-container text-on-tertiary-container px-2 py-0.5 rounded-full">
|
||||
{msg.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg overflow-hidden bg-surface-dim border border-outline-variant/20">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-on-surface/5 border-b border-outline-variant/10">
|
||||
<div className="w-2 h-2 rounded-full bg-error/40" />
|
||||
<div className="w-2 h-2 rounded-full bg-primary-container/40" />
|
||||
<div className="w-2 h-2 rounded-full bg-tertiary-container/40" />
|
||||
<span className="ml-2 text-[10px] font-mono text-outline">
|
||||
~/projects/companion/src/components
|
||||
</span>
|
||||
</div>
|
||||
<pre className="p-4 text-xs font-mono text-on-surface leading-5 overflow-x-auto">
|
||||
<code>{msg.command}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.role === 'progress') {
|
||||
return (
|
||||
<div key={msg.id} className="flex gap-4 items-start">
|
||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
||||
<Icon name="insights" className="text-outline" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 bg-surface-container-low rounded-lg border border-outline-variant/5">
|
||||
<Icon name="sync" className="text-primary text-lg" filled />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-on-surface">{msg.label}</p>
|
||||
<div className="w-full bg-surface-variant h-1 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full"
|
||||
style={{ width: `${msg.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-outline">{msg.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Team Strip (Persistent Navigation) ── */}
|
||||
<div className="absolute bottom-4 left-4 right-4 bg-surface-container-lowest/80 backdrop-blur-xl border border-outline-variant/20 rounded-2xl p-2 px-4 flex items-center gap-4 shadow-2xl z-10">
|
||||
<div className="text-[10px] font-bold text-outline uppercase tracking-tighter pr-4 border-r border-outline-variant/20">
|
||||
The Team
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{teamMembers.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => setViewingAgent(member.id)}
|
||||
className={`flex items-center gap-2 p-1.5 px-3 rounded-xl transition-colors ${
|
||||
member.active
|
||||
? 'bg-primary-fixed text-on-primary-fixed'
|
||||
: 'hover:bg-surface-container-high'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
|
||||
member.active ? 'bg-on-primary-fixed/10' : 'bg-surface-container-high'
|
||||
}`}>
|
||||
<Icon name="smart_toy" className="text-xs" />
|
||||
</div>
|
||||
<span className={`text-xs ${member.active ? 'font-bold' : 'font-semibold'}`}>
|
||||
{member.role}
|
||||
</span>
|
||||
{member.active && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<span className="text-[10px] text-outline italic">Teammate is thinking...</span>
|
||||
<button
|
||||
onClick={() => setViewingAgent(null)}
|
||||
className="w-8 h-8 rounded-full bg-on-surface text-surface flex items-center justify-center hover:scale-105 transition-transform"
|
||||
>
|
||||
<Icon name="arrow_back" className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MessageEntry } from '../types/session'
|
||||
|
||||
const { sendMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
sendMock,
|
||||
getMemberBySessionIdMock,
|
||||
sendMessageToMemberMock,
|
||||
handleTeamCreatedMock,
|
||||
handleTeamUpdateMock,
|
||||
handleTeamDeletedMock,
|
||||
} = vi.hoisted(() => ({
|
||||
sendMock: vi.fn(),
|
||||
getMemberBySessionIdMock: vi.fn<(sessionId: string) => any>(() => null),
|
||||
sendMessageToMemberMock: vi.fn(async () => {}),
|
||||
handleTeamCreatedMock: vi.fn(),
|
||||
handleTeamUpdateMock: vi.fn(),
|
||||
handleTeamDeletedMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/websocket', () => ({
|
||||
@ -25,9 +37,11 @@ vi.mock('../api/sessions', () => ({
|
||||
vi.mock('./teamStore', () => ({
|
||||
useTeamStore: {
|
||||
getState: () => ({
|
||||
handleTeamCreated: vi.fn(),
|
||||
handleTeamUpdate: vi.fn(),
|
||||
handleTeamDeleted: vi.fn(),
|
||||
getMemberBySessionId: getMemberBySessionIdMock,
|
||||
sendMessageToMember: sendMessageToMemberMock,
|
||||
handleTeamCreated: handleTeamCreatedMock,
|
||||
handleTeamUpdate: handleTeamUpdateMock,
|
||||
handleTeamDeleted: handleTeamDeletedMock,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@ -70,6 +84,9 @@ const initialState = useChatStore.getState()
|
||||
describe('chatStore history mapping', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset()
|
||||
getMemberBySessionIdMock.mockReset()
|
||||
getMemberBySessionIdMock.mockReturnValue(null)
|
||||
sendMessageToMemberMock.mockReset()
|
||||
useChatStore.setState({
|
||||
...initialState,
|
||||
sessions: {},
|
||||
@ -113,6 +130,28 @@ describe('chatStore history mapping', () => {
|
||||
expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' })
|
||||
})
|
||||
|
||||
it('surfaces teammate prompt content when mapping member transcript history', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user',
|
||||
timestamp: '2026-04-06T00:00:00.000Z',
|
||||
content: '<teammate-message teammate_id="security-reviewer">Review the auth diff and call out risks.</teammate-message>',
|
||||
},
|
||||
]
|
||||
|
||||
const mapped = mapHistoryMessagesToUiMessages(messages, {
|
||||
includeTeammateMessages: true,
|
||||
})
|
||||
|
||||
expect(mapped).toMatchObject([
|
||||
{
|
||||
type: 'user_text',
|
||||
content: 'Review the auth diff and call out risks.',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps parent tool linkage for live tool events', () => {
|
||||
// Initialize the session first
|
||||
useChatStore.setState({
|
||||
@ -247,4 +286,51 @@ describe('chatStore history mapping', () => {
|
||||
outputFile: '/tmp/agent-output.txt',
|
||||
})
|
||||
})
|
||||
|
||||
it('routes member-session messages through team mailbox delivery instead of websocket', async () => {
|
||||
const memberSessionId = 'team-member:security-reviewer@test-team'
|
||||
getMemberBySessionIdMock.mockReturnValue({
|
||||
agentId: 'security-reviewer@test-team',
|
||||
role: 'security-reviewer',
|
||||
status: 'running',
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[memberSessionId]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().sendMessage(memberSessionId, 'Check the latest regression')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sendMessageToMemberMock).toHaveBeenCalledWith(
|
||||
memberSessionId,
|
||||
'Check the latest regression',
|
||||
)
|
||||
expect(sendMock).not.toHaveBeenCalled()
|
||||
const sessionMessages = useChatStore.getState().sessions[memberSessionId]?.messages ?? []
|
||||
|
||||
expect(sessionMessages[sessionMessages.length - 1]).toMatchObject({
|
||||
type: 'user_text',
|
||||
content: 'Check the latest regression',
|
||||
pending: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,6 +6,7 @@ import { useSessionStore } from './sessionStore'
|
||||
import { useCLITaskStore } from './cliTaskStore'
|
||||
import { useTabStore } from './tabStore'
|
||||
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
||||
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
|
||||
import type { MessageEntry } from '../types/session'
|
||||
import type { PermissionMode } from '../types/settings'
|
||||
import type {
|
||||
@ -163,6 +164,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
|
||||
sendMessage: (sessionId, content, attachments?) => {
|
||||
const userFacingContent = content.trim()
|
||||
const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
|
||||
const uiAttachments: UIAttachment[] | undefined =
|
||||
attachments && attachments.length > 0
|
||||
? attachments.map((a) => ({
|
||||
@ -177,11 +179,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed')
|
||||
|
||||
set((s) => {
|
||||
const session = s.sessions[sessionId]
|
||||
if (!session) return s
|
||||
const session = s.sessions[sessionId] ?? createDefaultSessionState()
|
||||
|
||||
const newMessages = [...session.messages]
|
||||
if (allTasksDone) {
|
||||
if (!isMemberSession && allTasksDone) {
|
||||
newMessages.push({
|
||||
id: nextId(),
|
||||
type: 'task_summary',
|
||||
@ -194,15 +195,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
id: nextId(),
|
||||
type: 'user_text',
|
||||
content: userFacingContent,
|
||||
attachments: uiAttachments,
|
||||
attachments: isMemberSession ? undefined : uiAttachments,
|
||||
timestamp: Date.now(),
|
||||
...(isMemberSession ? { pending: true } : {}),
|
||||
})
|
||||
|
||||
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
if (!isMemberSession && session.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) }))
|
||||
}, 1000)
|
||||
const timer = !isMemberSession
|
||||
? setInterval(() => {
|
||||
set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) }))
|
||||
}, 1000)
|
||||
: null
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
@ -213,13 +217,36 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
chatState: 'thinking',
|
||||
elapsedSeconds: 0,
|
||||
streamingText: '',
|
||||
statusVerb: randomSpinnerVerb(),
|
||||
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
|
||||
elapsedTimer: timer,
|
||||
connectionState: isMemberSession ? 'connected' : session.connectionState,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if (isMemberSession) {
|
||||
void useTeamStore.getState().sendMessageToMember(sessionId, userFacingContent)
|
||||
.catch((err) => {
|
||||
set((s) => ({
|
||||
sessions: updateSessionIn(s.sessions, sessionId, (session) => ({
|
||||
chatState: 'idle',
|
||||
messages: [
|
||||
...session.messages,
|
||||
{
|
||||
id: nextId(),
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
code: 'TEAM_MEMBER_MESSAGE_FAILED',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
})),
|
||||
}))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
wsManager.send(sessionId, { type: 'user_message', content, attachments })
|
||||
},
|
||||
|
||||
@ -253,10 +280,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
try {
|
||||
const { messages } = await sessionsApi.getMessages(sessionId)
|
||||
const uiMessages = mapHistoryMessagesToUiMessages(messages)
|
||||
const restoredNotifications = reconstructAgentNotifications(messages)
|
||||
set((state) => {
|
||||
const session = state.sessions[sessionId]
|
||||
if (!session || session.messages.length > 0) return state
|
||||
return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages })) }
|
||||
return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({
|
||||
messages: uiMessages,
|
||||
agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications },
|
||||
})) }
|
||||
})
|
||||
const lastTodos = extractLastTodoWriteFromHistory(messages)
|
||||
if (lastTodos && lastTodos.length > 0) {
|
||||
@ -519,11 +550,140 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
|
||||
type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string }
|
||||
|
||||
export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] {
|
||||
/**
|
||||
* Check if text is a teammate-message (internal agent-to-agent communication).
|
||||
* Uses full open+close tag match to avoid false positives on user text
|
||||
* that merely mentions the tag name (e.g., pasting code or discussing the protocol).
|
||||
*/
|
||||
function isTeammateMessage(text: string): boolean {
|
||||
return text.includes('<teammate-message') && text.includes('</teammate-message>')
|
||||
}
|
||||
|
||||
const TEAMMATE_CONTENT_REGEX = /<teammate-message\s+teammate_id="([^"]+)"[^>]*>\n?([\s\S]*?)\n?<\/teammate-message>/g
|
||||
|
||||
function extractVisibleTeammateMessageContents(text: string): string[] {
|
||||
const contents: string[] = []
|
||||
|
||||
for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) {
|
||||
const content = match[2]?.trim()
|
||||
if (!content) continue
|
||||
|
||||
if (content.startsWith('{') && content.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||
if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) {
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// Keep non-JSON payloads that happen to look like JSON.
|
||||
}
|
||||
}
|
||||
|
||||
contents.push(content)
|
||||
}
|
||||
|
||||
return contents
|
||||
}
|
||||
|
||||
type HistoryMappingOptions = {
|
||||
includeTeammateMessages?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct agentTaskNotifications from history.
|
||||
*
|
||||
* During a live session, background agents report completion via system_notification
|
||||
* events (task_notification). These are NOT persisted in JSONL history. On reload,
|
||||
* we reconstruct them by correlating Agent tool_use names with <teammate-message>
|
||||
* teammate_ids found in subsequent user messages.
|
||||
*/
|
||||
export function reconstructAgentNotifications(messages: MessageEntry[]): Record<string, AgentTaskNotification> {
|
||||
// Step 1: Collect Agent tool_use blocks → map agent name to toolUseId
|
||||
const agentNameToToolUseId = new Map<string, string>()
|
||||
|
||||
for (const msg of messages) {
|
||||
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content as AssistantHistoryBlock[]) {
|
||||
if (block.type === 'tool_use' && block.name === 'Agent' && block.id) {
|
||||
const input = block.input as Record<string, unknown> | undefined
|
||||
const name = input?.name as string | undefined
|
||||
// Keep first toolUseId per name (consistent with first-wins for teammateContent)
|
||||
if (name && !agentNameToToolUseId.has(name)) agentNameToToolUseId.set(name, block.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (agentNameToToolUseId.size === 0) return {}
|
||||
|
||||
// Step 2: Extract <teammate-message> content by teammate_id
|
||||
// Skip lifecycle messages (shutdown_approved, idle_notification, etc.)
|
||||
// which overwrite actual review content if stored later in history
|
||||
const teammateContent = new Map<string, string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== 'user') continue
|
||||
const text = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? (msg.content as Array<{ type?: string; text?: string }>).filter((b) => b.type === 'text' && b.text).map((b) => b.text).join('\n')
|
||||
: ''
|
||||
if (!text.includes('<teammate-message')) continue
|
||||
for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) {
|
||||
if (match[1] && match[2]) {
|
||||
const content = match[2].trim()
|
||||
// Skip lifecycle JSON messages (shutdown, idle, terminated notifications)
|
||||
if (content.startsWith('{') && content.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||
if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) continue
|
||||
} catch { /* not JSON, keep it */ }
|
||||
}
|
||||
// Only store the first meaningful content per teammate (avoid overwrite by later lifecycle msgs)
|
||||
if (!teammateContent.has(match[1])) {
|
||||
teammateContent.set(match[1], content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Correlate and build notifications
|
||||
const notifications: Record<string, AgentTaskNotification> = {}
|
||||
for (const [name, toolUseId] of agentNameToToolUseId) {
|
||||
const content = teammateContent.get(name)
|
||||
if (content) {
|
||||
notifications[toolUseId] = {
|
||||
taskId: toolUseId,
|
||||
toolUseId,
|
||||
status: 'completed',
|
||||
summary: content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
export function mapHistoryMessagesToUiMessages(
|
||||
messages: MessageEntry[],
|
||||
options?: HistoryMappingOptions,
|
||||
): UIMessage[] {
|
||||
const includeTeammateMessages = options?.includeTeammateMessages === true
|
||||
const uiMessages: UIMessage[] = []
|
||||
for (const msg of messages) {
|
||||
const timestamp = new Date(msg.timestamp).getTime()
|
||||
if (msg.type === 'user' && typeof msg.content === 'string') {
|
||||
if (isTeammateMessage(msg.content)) {
|
||||
if (!includeTeammateMessages) continue
|
||||
const teammateContents = extractVisibleTeammateMessageContents(msg.content)
|
||||
if (teammateContents.length === 0) continue
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'user_text',
|
||||
content: teammateContents.join('\n\n'),
|
||||
timestamp,
|
||||
})
|
||||
continue
|
||||
}
|
||||
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
|
||||
continue
|
||||
}
|
||||
@ -543,7 +703,12 @@ export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMess
|
||||
const textParts: string[] = []
|
||||
const attachments: UIAttachment[] = []
|
||||
for (const block of msg.content as UserHistoryBlock[]) {
|
||||
if (block.type === 'text' && block.text) textParts.push(block.text)
|
||||
if (block.type === 'text' && block.text && isTeammateMessage(block.text)) {
|
||||
if (!includeTeammateMessages) continue
|
||||
textParts.push(...extractVisibleTeammateMessageContents(block.text))
|
||||
} else if (block.type === 'text' && block.text) {
|
||||
textParts.push(block.text)
|
||||
}
|
||||
else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type })
|
||||
else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' })
|
||||
else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
|
||||
|
||||
@ -1,21 +1,134 @@
|
||||
import { create } from 'zustand'
|
||||
import { teamsApi } from '../api/teams'
|
||||
import type { TeamSummary, TeamDetail, TeamMember, TranscriptMessage, AgentColor } from '../types/team'
|
||||
import type { TeamSummary, TeamDetail, TeamMember, AgentColor } from '../types/team'
|
||||
import { AGENT_COLORS } from '../types/team'
|
||||
import type { TeamMemberStatus } from '../types/chat'
|
||||
import type { TeamMemberStatus, UIMessage } from '../types/chat'
|
||||
import { useChatStore, mapHistoryMessagesToUiMessages } from './chatStore'
|
||||
import { useTabStore } from './tabStore'
|
||||
|
||||
const MEMBER_POLL_INTERVAL_MS = 1500
|
||||
const MEMBER_TRANSCRIPT_MATCH_WINDOW_MS = 120_000
|
||||
|
||||
/** Generate a synthetic sessionId for team member tabs */
|
||||
const memberSessionId = (agentId: string) => `team-member:${agentId}`
|
||||
|
||||
/** Module-level timer for polling member transcript */
|
||||
let memberPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let polledMemberSessionId: string | null = null
|
||||
|
||||
function createMemberSessionState() {
|
||||
return {
|
||||
messages: [] as UIMessage[],
|
||||
chatState: 'idle' as const,
|
||||
connectionState: 'connected' as const,
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMemberStatus(status: string | undefined): TeamMember['status'] {
|
||||
if (status === 'running' || status === 'idle' || status === 'completed') {
|
||||
return status
|
||||
}
|
||||
return status === 'failed' ? 'error' : 'idle'
|
||||
}
|
||||
|
||||
function toTeamMember(raw: Record<string, unknown>): TeamMember {
|
||||
return {
|
||||
agentId: (raw.agentId as string) || '',
|
||||
name: raw.name as string | undefined,
|
||||
role:
|
||||
(raw.name as string) ||
|
||||
(raw.agentType as string) ||
|
||||
(raw.role as string) ||
|
||||
(raw.agentId as string) ||
|
||||
'',
|
||||
status: normalizeMemberStatus(raw.status as string | undefined),
|
||||
currentTask: raw.currentTask as string | undefined,
|
||||
color: raw.color as AgentColor | undefined,
|
||||
sessionId: raw.sessionId as string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function isPendingMemberMessage(message: UIMessage): message is Extract<UIMessage, { type: 'user_text' }> & { pending: true } {
|
||||
return message.type === 'user_text' && message.pending === true
|
||||
}
|
||||
|
||||
function transcriptAlreadyContainsMessage(
|
||||
transcriptMessages: UIMessage[],
|
||||
pendingMessage: Extract<UIMessage, { type: 'user_text' }> & { pending: true },
|
||||
): boolean {
|
||||
return transcriptMessages.some((message) => (
|
||||
message.type === 'user_text' &&
|
||||
message.pending !== true &&
|
||||
message.content === pendingMessage.content &&
|
||||
Math.abs(message.timestamp - pendingMessage.timestamp) <= MEMBER_TRANSCRIPT_MATCH_WINDOW_MS
|
||||
))
|
||||
}
|
||||
|
||||
function mergeMemberTranscriptMessages(
|
||||
existingMessages: UIMessage[],
|
||||
transcriptMessages: UIMessage[],
|
||||
): UIMessage[] {
|
||||
const pendingMessages = existingMessages.filter(isPendingMemberMessage).filter(
|
||||
(message) => !transcriptAlreadyContainsMessage(transcriptMessages, message),
|
||||
)
|
||||
|
||||
return pendingMessages.length > 0
|
||||
? [...transcriptMessages, ...pendingMessages]
|
||||
: transcriptMessages
|
||||
}
|
||||
|
||||
function syncMemberSessionMessages(
|
||||
sessionId: string,
|
||||
memberStatus: TeamMember['status'],
|
||||
messages: UIMessage[],
|
||||
) {
|
||||
const hasPendingMessages = messages.some(isPendingMemberMessage)
|
||||
useChatStore.setState((state) => {
|
||||
const existing = state.sessions[sessionId]
|
||||
const nextState = existing ?? createMemberSessionState()
|
||||
return {
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[sessionId]: {
|
||||
...nextState,
|
||||
messages,
|
||||
connectionState: 'connected',
|
||||
chatState:
|
||||
memberStatus === 'running' || hasPendingMessages
|
||||
? 'thinking'
|
||||
: 'idle',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type TeamStore = {
|
||||
teams: TeamSummary[]
|
||||
activeTeam: TeamDetail | null
|
||||
viewingAgentId: string | null
|
||||
agentTranscript: TranscriptMessage[]
|
||||
memberColors: Map<string, AgentColor>
|
||||
error: string | null
|
||||
|
||||
fetchTeams: () => Promise<void>
|
||||
fetchTeamDetail: (name: string) => Promise<void>
|
||||
fetchMemberTranscript: (teamName: string, agentId: string) => Promise<void>
|
||||
setViewingAgent: (agentId: string | null) => void
|
||||
getMemberBySessionId: (sessionId: string) => TeamMember | null
|
||||
refreshMemberSession: (sessionId: string) => Promise<void>
|
||||
openMemberSession: (member: TeamMember) => void
|
||||
sendMessageToMember: (sessionId: string, content: string) => Promise<void>
|
||||
startMemberPolling: (sessionId: string, force?: boolean) => void
|
||||
stopMemberPolling: () => void
|
||||
clearTeam: () => void
|
||||
|
||||
// WebSocket handlers
|
||||
@ -27,8 +140,6 @@ type TeamStore = {
|
||||
export const useTeamStore = create<TeamStore>((set, get) => ({
|
||||
teams: [],
|
||||
activeTeam: null,
|
||||
viewingAgentId: null,
|
||||
agentTranscript: [],
|
||||
memberColors: new Map(),
|
||||
error: null,
|
||||
|
||||
@ -45,7 +156,16 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
|
||||
fetchTeamDetail: async (name: string) => {
|
||||
set({ error: null })
|
||||
try {
|
||||
const detail = await teamsApi.get(name)
|
||||
const raw = await teamsApi.get(name) as Record<string, unknown>
|
||||
const rawMembers = Array.isArray(raw.members) ? raw.members : []
|
||||
const members: TeamMember[] = rawMembers.map((m: Record<string, unknown>) => toTeamMember(m))
|
||||
const detail: TeamDetail = {
|
||||
name: raw.name as string,
|
||||
leadAgentId: raw.leadAgentId as string | undefined,
|
||||
leadSessionId: raw.leadSessionId as string | undefined,
|
||||
members,
|
||||
createdAt: raw.createdAt != null ? String(raw.createdAt) : undefined,
|
||||
}
|
||||
// Assign colors to members
|
||||
const colors = new Map<string, AgentColor>()
|
||||
detail.members.forEach((m, i) => {
|
||||
@ -57,52 +177,164 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
fetchMemberTranscript: async (teamName: string, agentId: string) => {
|
||||
set({ error: null })
|
||||
getMemberBySessionId: (sessionId: string) => {
|
||||
const team = get().activeTeam
|
||||
if (!team) return null
|
||||
return team.members.find(
|
||||
(m) => m.sessionId === sessionId || memberSessionId(m.agentId) === sessionId,
|
||||
) ?? null
|
||||
},
|
||||
|
||||
refreshMemberSession: async (sessionId) => {
|
||||
const team = get().activeTeam
|
||||
const member = get().getMemberBySessionId(sessionId)
|
||||
if (!team || !member) return
|
||||
|
||||
try {
|
||||
const { messages } = await teamsApi.getMemberTranscript(teamName, agentId)
|
||||
set({ agentTranscript: messages, viewingAgentId: agentId })
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) })
|
||||
const { messages } = await teamsApi.getMemberTranscript(team.name, member.agentId)
|
||||
const asEntries = messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
type: msg.type,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
model: msg.model,
|
||||
parentToolUseId: msg.parentToolUseId,
|
||||
}))
|
||||
const transcriptMessages = mapHistoryMessagesToUiMessages(
|
||||
asEntries as Parameters<typeof mapHistoryMessagesToUiMessages>[0],
|
||||
{ includeTeammateMessages: true },
|
||||
)
|
||||
const existingMessages = useChatStore.getState().sessions[sessionId]?.messages ?? []
|
||||
const mergedMessages = mergeMemberTranscriptMessages(
|
||||
existingMessages,
|
||||
transcriptMessages,
|
||||
)
|
||||
syncMemberSessionMessages(sessionId, member.status, mergedMessages)
|
||||
} catch {
|
||||
const existingMessages = useChatStore.getState().sessions[sessionId]?.messages ?? []
|
||||
syncMemberSessionMessages(sessionId, member.status, existingMessages)
|
||||
}
|
||||
},
|
||||
|
||||
setViewingAgent: (agentId) => {
|
||||
if (!agentId) {
|
||||
set({ viewingAgentId: null, agentTranscript: [] })
|
||||
} else {
|
||||
set({ viewingAgentId: agentId })
|
||||
// Fetch transcript if we have an active team
|
||||
const team = get().activeTeam
|
||||
if (team) {
|
||||
get().fetchMemberTranscript(team.name, agentId)
|
||||
openMemberSession: (member: TeamMember) => {
|
||||
const team = get().activeTeam
|
||||
if (!team) return
|
||||
|
||||
get().stopMemberPolling()
|
||||
|
||||
const tabId = memberSessionId(member.agentId)
|
||||
useTabStore.getState().openTab(tabId, member.role, 'session')
|
||||
void get().refreshMemberSession(tabId)
|
||||
get().startMemberPolling(tabId)
|
||||
},
|
||||
|
||||
sendMessageToMember: async (sessionId, content) => {
|
||||
const team = get().activeTeam
|
||||
const member = get().getMemberBySessionId(sessionId)
|
||||
if (!team || !member) {
|
||||
throw new Error('Team member session is no longer available')
|
||||
}
|
||||
|
||||
await teamsApi.sendMemberMessage(team.name, member.agentId, content)
|
||||
get().startMemberPolling(sessionId, true)
|
||||
await get().refreshMemberSession(sessionId)
|
||||
},
|
||||
|
||||
startMemberPolling: (sessionId, force = false) => {
|
||||
const member = get().getMemberBySessionId(sessionId)
|
||||
if (!member) return
|
||||
|
||||
const hasPendingMessages =
|
||||
useChatStore.getState().sessions[sessionId]?.messages.some(isPendingMemberMessage) ?? false
|
||||
|
||||
if (!force && polledMemberSessionId === sessionId && memberPollTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (member.status !== 'running' && !hasPendingMessages) {
|
||||
get().stopMemberPolling()
|
||||
return
|
||||
}
|
||||
|
||||
get().stopMemberPolling()
|
||||
polledMemberSessionId = sessionId
|
||||
memberPollTimer = setInterval(() => {
|
||||
const currentTabId = useTabStore.getState().activeTabId
|
||||
if (currentTabId !== sessionId) {
|
||||
get().stopMemberPolling()
|
||||
return
|
||||
}
|
||||
}
|
||||
void get().refreshMemberSession(sessionId)
|
||||
}, MEMBER_POLL_INTERVAL_MS)
|
||||
},
|
||||
|
||||
clearTeam: () => set({ activeTeam: null, viewingAgentId: null, agentTranscript: [], memberColors: new Map() }),
|
||||
stopMemberPolling: () => {
|
||||
if (memberPollTimer) {
|
||||
clearInterval(memberPollTimer)
|
||||
memberPollTimer = null
|
||||
}
|
||||
polledMemberSessionId = null
|
||||
},
|
||||
|
||||
clearTeam: () => {
|
||||
get().stopMemberPolling()
|
||||
set({ activeTeam: null, memberColors: new Map() })
|
||||
},
|
||||
|
||||
handleTeamCreated: (teamName: string) => {
|
||||
set((s) => ({
|
||||
teams: [...s.teams, { name: teamName, memberCount: 0 }],
|
||||
}))
|
||||
// Auto-fetch detail
|
||||
get().fetchTeamDetail(teamName)
|
||||
setTimeout(() => get().fetchTeamDetail(teamName), 1500)
|
||||
setTimeout(() => get().fetchTeamDetail(teamName), 4000)
|
||||
setTimeout(() => get().fetchTeamDetail(teamName), 8000)
|
||||
},
|
||||
|
||||
handleTeamUpdate: (teamName: string, members: TeamMemberStatus[]) => {
|
||||
const team = get().activeTeam
|
||||
if (team && team.name === teamName) {
|
||||
if (members.length === 0) return
|
||||
|
||||
if (members.length > team.members.length) {
|
||||
get().fetchTeamDetail(teamName)
|
||||
}
|
||||
|
||||
const colors = get().memberColors
|
||||
const updatedMembers: TeamMember[] = members.map((m, i) => ({
|
||||
...m,
|
||||
color: colors.get(m.agentId) ?? AGENT_COLORS[i % AGENT_COLORS.length]!,
|
||||
}))
|
||||
const existingMap = new Map(team.members.map((m) => [m.agentId, m]))
|
||||
const incomingIds = new Set(members.map((m) => m.agentId))
|
||||
const kept = team.members.filter((m) => !incomingIds.has(m.agentId))
|
||||
const updatedMembers: TeamMember[] = [
|
||||
...kept,
|
||||
...members.map((m, i) => {
|
||||
const existing = existingMap.get(m.agentId)
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
name: existing?.name,
|
||||
agentId: m.agentId,
|
||||
role: m.role,
|
||||
status: normalizeMemberStatus(m.status),
|
||||
currentTask: m.currentTask,
|
||||
color: colors.get(m.agentId) ?? AGENT_COLORS[i % AGENT_COLORS.length]!,
|
||||
sessionId: existing?.sessionId,
|
||||
}
|
||||
}),
|
||||
]
|
||||
set({ activeTeam: { ...team, members: updatedMembers } })
|
||||
|
||||
const currentTabId = useTabStore.getState().activeTabId
|
||||
if (currentTabId) {
|
||||
const viewedMember = get().getMemberBySessionId(currentTabId)
|
||||
if (viewedMember) {
|
||||
void get().refreshMemberSession(currentTabId)
|
||||
get().startMemberPolling(currentTabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
handleTeamDeleted: (teamName: string) => {
|
||||
get().stopMemberPolling()
|
||||
set((s) => ({
|
||||
teams: s.teams.filter((t) => t.name !== teamName),
|
||||
activeTeam: s.activeTeam?.name === teamName ? null : s.activeTeam,
|
||||
|
||||
@ -81,7 +81,7 @@ export type TaskSummaryItem = {
|
||||
}
|
||||
|
||||
export type UIMessage =
|
||||
| { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[] }
|
||||
| { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
|
||||
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string }
|
||||
| { id: string; type: 'thinking'; content: string; timestamp: number }
|
||||
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }
|
||||
|
||||
@ -8,25 +8,31 @@ export type TeamSummary = {
|
||||
|
||||
export type TeamMember = {
|
||||
agentId: string
|
||||
name?: string
|
||||
role: string
|
||||
status: 'running' | 'idle' | 'completed' | 'error'
|
||||
currentTask?: string
|
||||
color?: AgentColor
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export type TeamDetail = {
|
||||
name: string
|
||||
leadAgentId?: string
|
||||
leadSessionId?: string
|
||||
members: TeamMember[]
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export type TranscriptMessage = {
|
||||
id: string
|
||||
type: string
|
||||
content: unknown
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export type AgentColor = 'red' | 'blue' | 'green' | 'yellow' | 'purple' | 'orange' | 'pink' | 'cyan'
|
||||
|
||||
export const AGENT_COLORS: AgentColor[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']
|
||||
|
||||
/** Lifecycle message types that should be filtered from agent output display */
|
||||
export const AGENT_LIFECYCLE_TYPES = new Set([
|
||||
'shutdown_approved',
|
||||
'shutdown_rejected',
|
||||
'shutdown_request',
|
||||
'teammate_terminated',
|
||||
'idle_notification',
|
||||
])
|
||||
|
||||
@ -126,13 +126,13 @@ describe('TeamWatcher.extractMemberStatuses', () => {
|
||||
expect(statuses).toHaveLength(2)
|
||||
expect(statuses[0]).toEqual({
|
||||
agentId: 'agent-lead',
|
||||
role: 'lead',
|
||||
role: 'Lead Agent',
|
||||
status: 'running',
|
||||
currentTask: undefined,
|
||||
})
|
||||
expect(statuses[1]).toEqual({
|
||||
agentId: 'agent-worker',
|
||||
role: 'worker',
|
||||
role: 'Worker Agent',
|
||||
status: 'idle',
|
||||
currentTask: undefined,
|
||||
})
|
||||
@ -153,12 +153,12 @@ describe('TeamWatcher.extractMemberStatuses', () => {
|
||||
expect(statuses[1]!.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('should use agentType as role', () => {
|
||||
it('should prefer member name as role when present', () => {
|
||||
const config = makeTeamConfig()
|
||||
const statuses = watcher.extractMemberStatuses(config)
|
||||
|
||||
expect(statuses[0]!.role).toBe('lead')
|
||||
expect(statuses[1]!.role).toBe('worker')
|
||||
expect(statuses[0]!.role).toBe('Lead Agent')
|
||||
expect(statuses[1]!.role).toBe('Worker Agent')
|
||||
})
|
||||
|
||||
it('should fall back to name when agentType is missing', () => {
|
||||
|
||||
@ -59,6 +59,20 @@ async function writeTranscriptFile(
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function writeSubagentTranscriptFile(
|
||||
projectDir: string,
|
||||
leadSessionId: string,
|
||||
fileName: string,
|
||||
entries: Record<string, unknown>[],
|
||||
): Promise<string> {
|
||||
const dir = path.join(tmpDir, 'projects', projectDir, leadSessionId, 'subagents')
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const filePath = path.join(dir, fileName)
|
||||
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
return filePath
|
||||
}
|
||||
|
||||
/** Create a standard team config for testing. */
|
||||
function makeTeamConfig(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
@ -164,16 +178,61 @@ describe('TeamService', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should return team detail with members', async () => {
|
||||
await writeTeamConfig('detail-team', makeTeamConfig({ name: 'detail-team' }))
|
||||
await writeTeamConfig(
|
||||
'detail-team',
|
||||
makeTeamConfig({
|
||||
name: 'detail-team',
|
||||
leadSessionId: 'lead-session-xyz',
|
||||
}),
|
||||
)
|
||||
|
||||
const detail = await service.getTeam('detail-team')
|
||||
expect(detail.name).toBe('detail-team')
|
||||
expect(detail.leadAgentId).toBe('agent-lead')
|
||||
expect(detail.leadSessionId).toBe('lead-session-xyz')
|
||||
expect(detail.members).toHaveLength(2)
|
||||
expect(detail.members[0]!.agentId).toBe('agent-lead')
|
||||
expect(detail.members[1]!.agentId).toBe('agent-worker')
|
||||
})
|
||||
|
||||
it('should discover missing in-process members from subagent transcripts', async () => {
|
||||
await writeTeamConfig(
|
||||
'subagent-team',
|
||||
makeTeamConfig({
|
||||
name: 'subagent-team',
|
||||
leadSessionId: 'lead-session-subagents',
|
||||
members: [
|
||||
{
|
||||
agentId: 'agent-lead',
|
||||
name: 'Lead Agent',
|
||||
agentType: 'lead',
|
||||
joinedAt: 1700000000000,
|
||||
tmuxPaneId: '%0',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-lead-001',
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
await writeSubagentTranscriptFile(
|
||||
'-tmp-project',
|
||||
'lead-session-subagents',
|
||||
'agent-1.jsonl',
|
||||
[
|
||||
{
|
||||
agentName: 'security-reviewer',
|
||||
agentId: 'security-reviewer@subagent-team',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
const detail = await service.getTeam('subagent-team')
|
||||
expect(detail.members.some((member) => member.name === 'security-reviewer')).toBe(true)
|
||||
})
|
||||
|
||||
it('should derive running status for active member', async () => {
|
||||
await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' }))
|
||||
|
||||
@ -274,7 +333,7 @@ describe('TeamService', () => {
|
||||
|
||||
expect(
|
||||
service.getMemberTranscript('member-team', 'nonexistent-agent'),
|
||||
).rejects.toThrow('Member not found')
|
||||
).rejects.toThrow('Team member not found')
|
||||
})
|
||||
|
||||
it('should skip meta entries in transcript', async () => {
|
||||
@ -301,6 +360,61 @@ describe('TeamService', () => {
|
||||
expect(messages[0]!.id).toBe('msg-real')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// sendMemberMessage
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should write member messages into the teammate inbox', async () => {
|
||||
await writeTeamConfig('mailbox-team', makeTeamConfig({ name: 'mailbox-team' }))
|
||||
|
||||
await service.sendMemberMessage(
|
||||
'mailbox-team',
|
||||
'agent-worker',
|
||||
'Please review the latest diff',
|
||||
)
|
||||
|
||||
const inboxPath = path.join(
|
||||
tmpDir,
|
||||
'teams',
|
||||
'mailbox-team',
|
||||
'inboxes',
|
||||
'Worker-Agent.json',
|
||||
)
|
||||
const rawInbox = await fs.readFile(inboxPath, 'utf-8')
|
||||
const inbox = JSON.parse(rawInbox) as Array<{
|
||||
from: string
|
||||
text: string
|
||||
read: boolean
|
||||
}>
|
||||
|
||||
expect(inbox).toHaveLength(1)
|
||||
expect(inbox[0]).toMatchObject({
|
||||
from: 'user',
|
||||
text: 'Please review the latest diff',
|
||||
read: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should send messages to inbox-discovered members', async () => {
|
||||
await writeTeamConfig('inbox-team', makeTeamConfig({ name: 'inbox-team' }))
|
||||
const inboxDir = path.join(tmpDir, 'teams', 'inbox-team', 'inboxes')
|
||||
await fs.mkdir(inboxDir, { recursive: true })
|
||||
await fs.writeFile(path.join(inboxDir, 'security-reviewer.json'), '[]', 'utf-8')
|
||||
|
||||
await service.sendMemberMessage(
|
||||
'inbox-team',
|
||||
'security-reviewer@inbox-team',
|
||||
'Check the auth changes',
|
||||
)
|
||||
|
||||
const rawInbox = await fs.readFile(
|
||||
path.join(inboxDir, 'security-reviewer.json'),
|
||||
'utf-8',
|
||||
)
|
||||
const inbox = JSON.parse(rawInbox) as Array<{ text: string }>
|
||||
expect(inbox.at(-1)?.text).toBe('Check the auth changes')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// deleteTeam
|
||||
// --------------------------------------------------------------------------
|
||||
@ -395,7 +509,10 @@ describe('Teams API', () => {
|
||||
})
|
||||
|
||||
it('GET /api/teams/:name should return team detail', async () => {
|
||||
await writeTeamConfig('detail', makeTeamConfig({ name: 'detail' }))
|
||||
await writeTeamConfig(
|
||||
'detail',
|
||||
makeTeamConfig({ name: 'detail', leadSessionId: 'leader-session-id' }),
|
||||
)
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/teams/detail`)
|
||||
expect(res.status).toBe(200)
|
||||
@ -403,10 +520,12 @@ describe('Teams API', () => {
|
||||
const body = (await res.json()) as {
|
||||
name: string
|
||||
leadAgentId: string
|
||||
leadSessionId?: string
|
||||
members: Array<{ agentId: string }>
|
||||
}
|
||||
expect(body.name).toBe('detail')
|
||||
expect(body.leadAgentId).toBe('agent-lead')
|
||||
expect(body.leadSessionId).toBe('leader-session-id')
|
||||
expect(body.members).toHaveLength(2)
|
||||
})
|
||||
|
||||
@ -448,6 +567,30 @@ describe('Teams API', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('POST /api/teams/:name/members/:id/messages should enqueue a mailbox message', async () => {
|
||||
await writeTeamConfig('send-team', makeTeamConfig({ name: 'send-team' }))
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/teams/send-team/members/agent-worker/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: 'Please continue with the failing test' }),
|
||||
},
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ok: boolean }
|
||||
expect(body.ok).toBe(true)
|
||||
|
||||
const rawInbox = await fs.readFile(
|
||||
path.join(tmpDir, 'teams', 'send-team', 'inboxes', 'Worker-Agent.json'),
|
||||
'utf-8',
|
||||
)
|
||||
const inbox = JSON.parse(rawInbox) as Array<{ text: string }>
|
||||
expect(inbox.at(-1)?.text).toBe('Please continue with the failing test')
|
||||
})
|
||||
|
||||
it('DELETE /api/teams/:name should delete team', async () => {
|
||||
const config = makeTeamConfig({ name: 'del-team' })
|
||||
for (const member of (config as { members: Array<Record<string, unknown>> }).members) {
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
* GET /api/teams — 列出所有团队
|
||||
* GET /api/teams/:name — 获取团队详情
|
||||
* GET /api/teams/:name/members/:id/transcript — 获取成员 transcript
|
||||
* POST /api/teams/:name/members/:id/messages — 给成员发送消息
|
||||
* DELETE /api/teams/:name — 删除团队
|
||||
*/
|
||||
|
||||
@ -38,6 +39,26 @@ export async function handleTeamsApi(
|
||||
return Response.json({ messages })
|
||||
}
|
||||
|
||||
// ── POST /api/teams/:name/members/:id/messages ─────────────────────────
|
||||
if (
|
||||
method === 'POST' &&
|
||||
teamName &&
|
||||
segments[3] === 'members' &&
|
||||
segments[4] &&
|
||||
segments[5] === 'messages'
|
||||
) {
|
||||
const agentId = decodeURIComponent(segments[4])
|
||||
let body: { content?: string }
|
||||
try {
|
||||
body = (await req.json()) as { content?: string }
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
|
||||
await teamService.sendMemberMessage(teamName, agentId, body.content ?? '')
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
// ── GET /api/teams/:name ──────────────────────────────────────────────
|
||||
if (method === 'GET' && teamName) {
|
||||
const team = await teamService.getTeam(teamName)
|
||||
|
||||
@ -2,14 +2,17 @@
|
||||
* TeamService — 读取 CLI 生成的 Agent Teams 配置
|
||||
*
|
||||
* Team 配置存储在 ~/.claude/teams/{name}/config.json
|
||||
* 成员 transcript 存储为 JSONL 文件,通过 sessionId 在 ~/.claude/projects/ 下定位。
|
||||
* 服务端只读取,不负责创建团队(由 CLI 的 TeamCreate 工具完成)。
|
||||
* 成员 transcript 存储为 JSONL 文件:
|
||||
* - 有 sessionId 的成员: ~/.claude/projects/{project}/{sessionId}.jsonl
|
||||
* - in-process 成员 (无 sessionId): ~/.claude/projects/{project}/{leadSessionId}/subagents/agent-*.jsonl
|
||||
* 成员发现: config.json + inboxes/ 目录 (解决并发写入丢失成员的问题)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { writeToMailbox } from '../../utils/teammateMailbox.js'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -19,6 +22,7 @@ export type TeamMember = {
|
||||
agentType?: string
|
||||
model?: string
|
||||
color?: string
|
||||
backendType?: string
|
||||
status: 'running' | 'completed' | 'idle' | 'failed'
|
||||
joinedAt: number
|
||||
cwd: string
|
||||
@ -35,6 +39,7 @@ export type TeamSummary = {
|
||||
|
||||
export type TeamDetail = TeamSummary & {
|
||||
leadAgentId: string
|
||||
leadSessionId?: string
|
||||
members: TeamMember[]
|
||||
}
|
||||
|
||||
@ -43,6 +48,8 @@ export type TranscriptMessage = {
|
||||
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
|
||||
content: unknown
|
||||
timestamp: string
|
||||
model?: string
|
||||
parentToolUseId?: string
|
||||
}
|
||||
|
||||
/** Raw config.json structure written by CLI */
|
||||
@ -64,6 +71,7 @@ type TeamFileRaw = {
|
||||
cwd: string
|
||||
worktreePath?: string
|
||||
sessionId?: string
|
||||
backendType?: string
|
||||
isActive?: boolean
|
||||
mode?: string
|
||||
}>
|
||||
@ -103,7 +111,14 @@ export class TeamService {
|
||||
|
||||
try {
|
||||
const config = await this.loadTeamConfig(entry.name)
|
||||
teams.push(this.toSummary(config))
|
||||
// Include inbox-discovered members in the count
|
||||
const inboxNames = await this.discoverInboxMembers(entry.name)
|
||||
const configNames = new Set(config.members.map((m) => m.name))
|
||||
const extraCount = inboxNames.filter((n) => !configNames.has(n)).length
|
||||
const summary = this.toSummary(config)
|
||||
summary.memberCount += extraCount
|
||||
summary.activeMemberCount += extraCount // assume running if newly discovered
|
||||
teams.push(summary)
|
||||
} catch {
|
||||
// Skip malformed team directories
|
||||
}
|
||||
@ -123,15 +138,58 @@ export class TeamService {
|
||||
agentType: m.agentType,
|
||||
model: m.model,
|
||||
color: m.color,
|
||||
backendType: m.backendType,
|
||||
status: this.deriveStatus(m.isActive),
|
||||
joinedAt: m.joinedAt,
|
||||
cwd: m.cwd,
|
||||
sessionId: m.sessionId,
|
||||
}))
|
||||
|
||||
// Discover members from inboxes/ that aren't in config.json (race condition fix)
|
||||
const inboxNames = await this.discoverInboxMembers(name)
|
||||
const configNames = new Set(config.members.map((m) => m.name))
|
||||
|
||||
for (const inboxName of inboxNames) {
|
||||
if (!configNames.has(inboxName)) {
|
||||
members.push({
|
||||
agentId: `${inboxName}@${name}`,
|
||||
name: inboxName,
|
||||
agentType: 'general-purpose',
|
||||
status: 'running', // assume running since we can see their inbox
|
||||
joinedAt: config.createdAt,
|
||||
cwd: config.members[0]?.cwd || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (config.leadSessionId) {
|
||||
const subagentNames = await this.discoverSubagentMembers(
|
||||
config.leadSessionId,
|
||||
)
|
||||
for (const subagentName of subagentNames) {
|
||||
if (
|
||||
!configNames.has(subagentName) &&
|
||||
!members.some((member) => member.name === subagentName)
|
||||
) {
|
||||
members.push({
|
||||
agentId: `${subagentName}@${name}`,
|
||||
name: subagentName,
|
||||
status: 'running',
|
||||
joinedAt: config.createdAt,
|
||||
cwd: config.members[0]?.cwd || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...this.toSummary(config),
|
||||
leadAgentId: config.leadAgentId,
|
||||
leadSessionId: config.leadSessionId,
|
||||
memberCount: members.length,
|
||||
activeMemberCount: members.filter(
|
||||
(m) => m.status === 'running',
|
||||
).length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
@ -143,24 +201,68 @@ export class TeamService {
|
||||
agentId: string,
|
||||
): Promise<TranscriptMessage[]> {
|
||||
const config = await this.loadTeamConfig(teamName)
|
||||
|
||||
const member = config.members.find((m) => m.agentId === agentId)
|
||||
if (!member) {
|
||||
const memberName = await this.resolveMemberName(config, teamName, agentId)
|
||||
if (!memberName) {
|
||||
throw ApiError.notFound(
|
||||
`Member not found: ${agentId} in team ${teamName}`,
|
||||
`Team member not found: ${agentId} in team ${teamName}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!member.sessionId) {
|
||||
return []
|
||||
// Try config.json member with sessionId first
|
||||
const member = config.members.find((m) => m.agentId === agentId)
|
||||
if (member?.sessionId) {
|
||||
const jsonlPath = await this.findTranscriptFile(member.sessionId)
|
||||
if (jsonlPath) {
|
||||
return this.parseTranscriptFile(jsonlPath)
|
||||
}
|
||||
}
|
||||
|
||||
const jsonlPath = await this.findTranscriptFile(member.sessionId)
|
||||
if (!jsonlPath) {
|
||||
return []
|
||||
// Fallback: search subagents directory for this member's transcript
|
||||
if (config.leadSessionId) {
|
||||
const subagentPath = await this.findSubagentTranscript(
|
||||
config.leadSessionId,
|
||||
memberName,
|
||||
)
|
||||
if (subagentPath) {
|
||||
return this.parseTranscriptFile(subagentPath)
|
||||
}
|
||||
}
|
||||
|
||||
return this.parseTranscriptFile(jsonlPath)
|
||||
return []
|
||||
}
|
||||
|
||||
async sendMemberMessage(
|
||||
teamName: string,
|
||||
agentId: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const text = content.trim()
|
||||
if (!text) {
|
||||
throw ApiError.badRequest('content (string) is required in request body')
|
||||
}
|
||||
|
||||
const config = await this.loadTeamConfig(teamName)
|
||||
const recipientName = await this.resolveMemberName(
|
||||
config,
|
||||
teamName,
|
||||
agentId,
|
||||
)
|
||||
|
||||
if (!recipientName) {
|
||||
throw ApiError.notFound(
|
||||
`Team member not found: ${agentId} in team ${teamName}`,
|
||||
)
|
||||
}
|
||||
|
||||
await writeToMailbox(
|
||||
recipientName,
|
||||
{
|
||||
from: 'user',
|
||||
text,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
teamName,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Delete team ─────────────────────────────────────────────────────────
|
||||
@ -194,6 +296,25 @@ export class TeamService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover member names from the inboxes/ directory.
|
||||
* Each file `{name}.json` in inboxes/ represents a team member.
|
||||
* Excludes the team-lead inbox since the leader is already in config.
|
||||
*/
|
||||
private async discoverInboxMembers(teamName: string): Promise<string[]> {
|
||||
const inboxDir = path.join(this.getTeamsDir(), teamName, 'inboxes')
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(inboxDir)
|
||||
return files
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.map((f) => f.replace(/\.json$/, ''))
|
||||
.filter((name) => name !== 'team-lead')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private toSummary(config: TeamFileRaw): TeamSummary {
|
||||
const activeMemberCount = config.members.filter(
|
||||
(m) => m.isActive === undefined || m.isActive === true,
|
||||
@ -216,6 +337,79 @@ export class TeamService {
|
||||
return 'running'
|
||||
}
|
||||
|
||||
private async resolveMemberName(
|
||||
config: TeamFileRaw,
|
||||
teamName: string,
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const configMember = config.members.find((m) => m.agentId === agentId)
|
||||
if (configMember?.name) {
|
||||
return configMember.name
|
||||
}
|
||||
|
||||
const parsedName = agentId.includes('@') ? agentId.split('@')[0]! : agentId
|
||||
const inboxNames = await this.discoverInboxMembers(teamName)
|
||||
if (inboxNames.includes(parsedName)) {
|
||||
return parsedName
|
||||
}
|
||||
|
||||
if (config.leadSessionId) {
|
||||
const subagentNames = await this.discoverSubagentMembers(
|
||||
config.leadSessionId,
|
||||
)
|
||||
if (subagentNames.includes(parsedName)) {
|
||||
return parsedName
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async discoverSubagentMembers(leadSessionId: string): Promise<string[]> {
|
||||
const projectsDir = this.getProjectsDir()
|
||||
|
||||
try {
|
||||
await fs.access(projectsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const discovered = new Set<string>()
|
||||
const projectEntries = await fs.readdir(projectsDir, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
|
||||
for (const projEntry of projectEntries) {
|
||||
if (!projEntry.isDirectory()) continue
|
||||
|
||||
const subagentsDir = path.join(
|
||||
projectsDir,
|
||||
projEntry.name,
|
||||
leadSessionId,
|
||||
'subagents',
|
||||
)
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
files = await fs.readdir(subagentsDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
const discoveredName = await this.extractSubagentName(
|
||||
path.join(subagentsDir, file),
|
||||
)
|
||||
if (discoveredName && discoveredName !== 'team-lead') {
|
||||
discovered.add(discoveredName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...discovered]
|
||||
}
|
||||
|
||||
/** Search ~/.claude/projects/ for a JSONL file matching the sessionId. */
|
||||
private async findTranscriptFile(
|
||||
sessionId: string,
|
||||
@ -247,6 +441,122 @@ export class TeamService {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Search subagents directory for a specific member's transcript.
|
||||
* Path: ~/.claude/projects/{project}/{leadSessionId}/subagents/agent-*.jsonl
|
||||
*
|
||||
* Matches by reading the first user message and checking for the member name
|
||||
* in the `<teammate-message>` content (e.g., "你是 **security-reviewer**").
|
||||
*/
|
||||
private async findSubagentTranscript(
|
||||
leadSessionId: string,
|
||||
memberName: string,
|
||||
): Promise<string | null> {
|
||||
const projectsDir = this.getProjectsDir()
|
||||
|
||||
try {
|
||||
await fs.access(projectsDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const projectEntries = await fs.readdir(projectsDir, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
|
||||
for (const projEntry of projectEntries) {
|
||||
if (!projEntry.isDirectory()) continue
|
||||
|
||||
const subagentsDir = path.join(
|
||||
projectsDir,
|
||||
projEntry.name,
|
||||
leadSessionId,
|
||||
'subagents',
|
||||
)
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
files = await fs.readdir(subagentsDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check each subagent file — find the one matching this member
|
||||
// Use the most recent file if multiple match (handles retries)
|
||||
let bestMatch: { path: string; mtime: number } | null = null
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
|
||||
const filePath = path.join(subagentsDir, file)
|
||||
|
||||
try {
|
||||
const head = await this.readTranscriptHead(filePath)
|
||||
if (
|
||||
head.includes(`"${memberName}"`) ||
|
||||
head.includes(`**${memberName}**`) ||
|
||||
head.includes(`name":"${memberName}`) ||
|
||||
(await this.extractSubagentName(filePath)) === memberName
|
||||
) {
|
||||
const stat = await fs.stat(filePath)
|
||||
if (!bestMatch || stat.mtimeMs > bestMatch.mtime) {
|
||||
bestMatch = { path: filePath, mtime: stat.mtimeMs }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch) {
|
||||
return bestMatch.path
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async readTranscriptHead(filePath: string): Promise<string> {
|
||||
const fd = await fs.open(filePath, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(8192)
|
||||
const { bytesRead } = await fd.read(buf, 0, 8192, 0)
|
||||
return buf.toString('utf-8', 0, bytesRead)
|
||||
} finally {
|
||||
await fd.close()
|
||||
}
|
||||
}
|
||||
|
||||
private async extractSubagentName(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const head = await this.readTranscriptHead(filePath)
|
||||
const lines = head.split('\n').filter((line) => line.trim().length > 0)
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as Record<string, unknown>
|
||||
if (typeof entry.agentName === 'string' && entry.agentName.trim()) {
|
||||
return entry.agentName
|
||||
}
|
||||
if (typeof entry.agentId === 'string' && entry.agentId.includes('@')) {
|
||||
return entry.agentId.split('@')[0] ?? null
|
||||
}
|
||||
} catch {
|
||||
// Ignore partial or non-JSON lines in the preview window.
|
||||
}
|
||||
}
|
||||
|
||||
const nameMatch =
|
||||
head.match(/"agentName"\s*:\s*"([^"]+)"/) ||
|
||||
head.match(/"name"\s*:\s*"([^"]+)"/) ||
|
||||
head.match(/\*\*([a-zA-Z0-9_-]+)\*\*/)
|
||||
|
||||
return nameMatch?.[1] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a JSONL transcript file into messages. */
|
||||
private async parseTranscriptFile(
|
||||
filePath: string,
|
||||
@ -281,6 +591,8 @@ export class TeamService {
|
||||
content: entry.message ?? entry.content ?? null,
|
||||
timestamp:
|
||||
(entry.timestamp as string) || new Date().toISOString(),
|
||||
...(typeof entry.parentToolUseId === 'string' ? { parentToolUseId: entry.parentToolUseId } : {}),
|
||||
...(typeof entry.model === 'string' ? { model: entry.model } : {}),
|
||||
}
|
||||
|
||||
messages.push(message)
|
||||
|
||||
@ -100,10 +100,18 @@ export class TeamWatcher {
|
||||
try {
|
||||
const config = JSON.parse(content)
|
||||
const members = this.extractMemberStatuses(config)
|
||||
this.broadcast({ type: 'team_update', teamName, members })
|
||||
// Merge inbox-discovered members that are missing from config
|
||||
const inboxMembers = this.discoverInboxMembers(teamsDir, teamName, config)
|
||||
const subagentMembers = this.discoverSubagentMembers(teamsDir, config)
|
||||
const allMembers = [...members, ...inboxMembers, ...subagentMembers]
|
||||
this.broadcast({ type: 'team_update', teamName, members: allMembers })
|
||||
} catch {
|
||||
// JSON parse failed -- broadcast with empty members
|
||||
this.broadcast({ type: 'team_update', teamName, members: [] })
|
||||
// JSON parse failed (likely truncated write) — try to recover partial members
|
||||
const recovered = this.recoverPartialMembers(content)
|
||||
if (recovered.length > 0) {
|
||||
this.broadcast({ type: 'team_update', teamName, members: recovered })
|
||||
}
|
||||
// If nothing recoverable, skip broadcast entirely — don't send empty members
|
||||
}
|
||||
}
|
||||
// else: content unchanged, nothing to do
|
||||
@ -136,7 +144,7 @@ export class TeamWatcher {
|
||||
const status = this.deriveStatus(m.isActive as boolean | undefined)
|
||||
return {
|
||||
agentId: (m.agentId as string) || '',
|
||||
role: (m.agentType as string) || (m.name as string) || 'member',
|
||||
role: (m.name as string) || (m.agentType as string) || 'member',
|
||||
status,
|
||||
currentTask: (m.currentTask as string) || undefined,
|
||||
}
|
||||
@ -149,6 +157,171 @@ export class TeamWatcher {
|
||||
return 'running'
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover members from inboxes/ that aren't in config.json.
|
||||
* Fixes the race condition where concurrent writes to config.json lose members.
|
||||
*/
|
||||
private discoverInboxMembers(
|
||||
teamsDir: string,
|
||||
teamName: string,
|
||||
config: Record<string, unknown>,
|
||||
): TeamMemberStatus[] {
|
||||
const inboxDir = path.join(teamsDir, teamName, 'inboxes')
|
||||
const configMembers = Array.isArray(config.members) ? config.members : []
|
||||
const configNames = new Set(
|
||||
configMembers.map((m: Record<string, unknown>) => m.name as string),
|
||||
)
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(inboxDir)
|
||||
const extra: TeamMemberStatus[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue
|
||||
const name = file.replace(/\.json$/, '')
|
||||
if (name === 'team-lead' || configNames.has(name)) continue
|
||||
|
||||
extra.push({
|
||||
agentId: `${name}@${teamName}`,
|
||||
role: name,
|
||||
status: 'running', // assume running — they have an inbox
|
||||
})
|
||||
}
|
||||
|
||||
return extra
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private discoverSubagentMembers(
|
||||
teamsDir: string,
|
||||
config: Record<string, unknown>,
|
||||
): TeamMemberStatus[] {
|
||||
const leadSessionId =
|
||||
typeof config.leadSessionId === 'string' ? config.leadSessionId : null
|
||||
const teamName = typeof config.name === 'string' ? config.name : 'team'
|
||||
if (!leadSessionId) return []
|
||||
|
||||
const configMembers = Array.isArray(config.members) ? config.members : []
|
||||
const configNames = new Set(
|
||||
configMembers.map((m: Record<string, unknown>) => m.name as string),
|
||||
)
|
||||
const projectsDir = path.join(path.dirname(teamsDir), 'projects')
|
||||
|
||||
try {
|
||||
const projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true })
|
||||
const extra = new Map<string, TeamMemberStatus>()
|
||||
|
||||
for (const entry of projectEntries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const subagentsDir = path.join(
|
||||
projectsDir,
|
||||
entry.name,
|
||||
leadSessionId,
|
||||
'subagents',
|
||||
)
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
files = fs.readdirSync(subagentsDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
const inferredName = this.extractSubagentName(
|
||||
path.join(subagentsDir, file),
|
||||
)
|
||||
if (
|
||||
inferredName &&
|
||||
inferredName !== 'team-lead' &&
|
||||
!configNames.has(inferredName) &&
|
||||
!extra.has(inferredName)
|
||||
) {
|
||||
extra.set(inferredName, {
|
||||
agentId: `${inferredName}@${teamName}`,
|
||||
role: inferredName,
|
||||
status: 'running',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...extra.values()]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover member data from truncated/corrupted JSON.
|
||||
* Extracts agentId values via regex and constructs minimal member statuses.
|
||||
*/
|
||||
private recoverPartialMembers(rawContent: string): TeamMemberStatus[] {
|
||||
const members: TeamMemberStatus[] = []
|
||||
// Match complete member-like objects: find "agentId":"..." patterns
|
||||
const agentIdRegex = /"agentId"\s*:\s*"([^"]+)"/g
|
||||
const nameRegex = /"(?:agentType|name)"\s*:\s*"([^"]+)"/g
|
||||
const isActiveRegex = /"isActive"\s*:\s*(true|false)/g
|
||||
|
||||
const agentIds: string[] = []
|
||||
const names: string[] = []
|
||||
const activeStates: (boolean | undefined)[] = []
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = agentIdRegex.exec(rawContent)) !== null) {
|
||||
agentIds.push(match[1]!)
|
||||
}
|
||||
while ((match = nameRegex.exec(rawContent)) !== null) {
|
||||
names.push(match[1]!)
|
||||
}
|
||||
while ((match = isActiveRegex.exec(rawContent)) !== null) {
|
||||
activeStates.push(match[1] === 'true')
|
||||
}
|
||||
|
||||
for (let i = 0; i < agentIds.length; i++) {
|
||||
members.push({
|
||||
agentId: agentIds[i]!,
|
||||
role: names[i] || 'member',
|
||||
status: this.deriveStatus(activeStates[i]),
|
||||
})
|
||||
}
|
||||
|
||||
return members
|
||||
}
|
||||
|
||||
private extractSubagentName(filePath: string): string | null {
|
||||
try {
|
||||
const head = fs.readFileSync(filePath, 'utf-8').slice(0, 8192)
|
||||
const lines = head.split('\n').filter((line) => line.trim().length > 0)
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as Record<string, unknown>
|
||||
if (typeof entry.agentName === 'string' && entry.agentName.trim()) {
|
||||
return entry.agentName
|
||||
}
|
||||
if (typeof entry.agentId === 'string' && entry.agentId.includes('@')) {
|
||||
return entry.agentId.split('@')[0] ?? null
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed preview lines.
|
||||
}
|
||||
}
|
||||
|
||||
const match =
|
||||
head.match(/"agentName"\s*:\s*"([^"]+)"/) ||
|
||||
head.match(/"name"\s*:\s*"([^"]+)"/) ||
|
||||
head.match(/\*\*([a-zA-Z0-9_-]+)\*\*/)
|
||||
|
||||
return match?.[1] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Broadcasting ───────────────────────────────────────────────────────
|
||||
|
||||
private broadcast(message: ServerMessage): void {
|
||||
|
||||
@ -51,10 +51,10 @@ import {
|
||||
} from '../../utils/swarm/spawnInProcess.js'
|
||||
import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js'
|
||||
import {
|
||||
mutateTeamFileAsync,
|
||||
readTeamFileAsync,
|
||||
sanitizeAgentName,
|
||||
sanitizeName,
|
||||
writeTeamFileAsync,
|
||||
} from '../../utils/swarm/teamHelpers.js'
|
||||
import {
|
||||
assignTeammateColor,
|
||||
@ -485,28 +485,24 @@ async function handleSpawnSplitPane(
|
||||
toolUseId: context.toolUseId,
|
||||
})
|
||||
|
||||
// Register agent in the team file
|
||||
const teamFile = await readTeamFileAsync(teamName)
|
||||
if (!teamFile) {
|
||||
throw new Error(
|
||||
`Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
|
||||
)
|
||||
}
|
||||
teamFile.members.push({
|
||||
agentId: teammateId,
|
||||
name: sanitizedName,
|
||||
agentType: agent_type,
|
||||
model,
|
||||
prompt,
|
||||
color: teammateColor,
|
||||
planModeRequired: plan_mode_required,
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: paneId,
|
||||
cwd: workingDir,
|
||||
subscriptions: [],
|
||||
backendType: detectionResult.backend.type,
|
||||
// Register agent in the team file using atomic read-modify-write to avoid
|
||||
// concurrent spawn clobbering other members.
|
||||
await mutateTeamFileAsync(teamName, (teamFile) => {
|
||||
teamFile.members.push({
|
||||
agentId: teammateId,
|
||||
name: sanitizedName,
|
||||
agentType: agent_type,
|
||||
model,
|
||||
prompt,
|
||||
color: teammateColor,
|
||||
planModeRequired: plan_mode_required,
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: paneId,
|
||||
cwd: workingDir,
|
||||
subscriptions: [],
|
||||
backendType: detectionResult.backend.type,
|
||||
})
|
||||
})
|
||||
await writeTeamFileAsync(teamName, teamFile)
|
||||
|
||||
// Send initial instructions to teammate via mailbox
|
||||
// The teammate's inbox poller will pick this up and submit it as their first turn
|
||||
@ -699,28 +695,22 @@ async function handleSpawnSeparateWindow(
|
||||
toolUseId: context.toolUseId,
|
||||
})
|
||||
|
||||
// Register agent in the team file
|
||||
const teamFile = await readTeamFileAsync(teamName)
|
||||
if (!teamFile) {
|
||||
throw new Error(
|
||||
`Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
|
||||
)
|
||||
}
|
||||
teamFile.members.push({
|
||||
agentId: teammateId,
|
||||
name: sanitizedName,
|
||||
agentType: agent_type,
|
||||
model,
|
||||
prompt,
|
||||
color: teammateColor,
|
||||
planModeRequired: plan_mode_required,
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: paneId,
|
||||
cwd: workingDir,
|
||||
subscriptions: [],
|
||||
backendType: 'tmux', // This handler always uses tmux directly
|
||||
await mutateTeamFileAsync(teamName, (teamFile) => {
|
||||
teamFile.members.push({
|
||||
agentId: teammateId,
|
||||
name: sanitizedName,
|
||||
agentType: agent_type,
|
||||
model,
|
||||
prompt,
|
||||
color: teammateColor,
|
||||
planModeRequired: plan_mode_required,
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: paneId,
|
||||
cwd: workingDir,
|
||||
subscriptions: [],
|
||||
backendType: 'tmux', // This handler always uses tmux directly
|
||||
})
|
||||
})
|
||||
await writeTeamFileAsync(teamName, teamFile)
|
||||
|
||||
// Send initial instructions to teammate via mailbox
|
||||
// The teammate's inbox poller will pick this up and submit it as their first turn
|
||||
@ -985,28 +975,22 @@ async function handleSpawnInProcess(
|
||||
}
|
||||
})
|
||||
|
||||
// Register agent in the team file
|
||||
const teamFile = await readTeamFileAsync(teamName)
|
||||
if (!teamFile) {
|
||||
throw new Error(
|
||||
`Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
|
||||
)
|
||||
}
|
||||
teamFile.members.push({
|
||||
agentId: teammateId,
|
||||
name: sanitizedName,
|
||||
agentType: agent_type,
|
||||
model,
|
||||
prompt,
|
||||
color: teammateColor,
|
||||
planModeRequired: plan_mode_required,
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: 'in-process',
|
||||
cwd: getCwd(),
|
||||
subscriptions: [],
|
||||
backendType: 'in-process',
|
||||
await mutateTeamFileAsync(teamName, (teamFile) => {
|
||||
teamFile.members.push({
|
||||
agentId: teammateId,
|
||||
name: sanitizedName,
|
||||
agentType: agent_type,
|
||||
model,
|
||||
prompt,
|
||||
color: teammateColor,
|
||||
planModeRequired: plan_mode_required,
|
||||
joinedAt: Date.now(),
|
||||
tmuxPaneId: 'in-process',
|
||||
cwd: getCwd(),
|
||||
subscriptions: [],
|
||||
backendType: 'in-process',
|
||||
})
|
||||
})
|
||||
await writeTeamFileAsync(teamName, teamFile)
|
||||
|
||||
// Note: Do NOT send the prompt via mailbox for in-process teammates.
|
||||
// In-process teammates receive the prompt directly via startInProcessTeammate().
|
||||
|
||||
@ -9,6 +9,7 @@ import { errorMessage, getErrnoCode } from '../errors.js'
|
||||
import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
|
||||
import { gitExe } from '../git.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import * as lockfile from '../lockfile.js'
|
||||
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import { getTasksDir, notifyTasksUpdated } from '../tasks.js'
|
||||
@ -181,6 +182,50 @@ export async function writeTeamFileAsync(
|
||||
await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically read-modify-write a team file under a file lock.
|
||||
* Use this for concurrent member updates such as teammate spawning.
|
||||
*/
|
||||
export async function mutateTeamFileAsync(
|
||||
teamName: string,
|
||||
mutator: (teamFile: TeamFile) => TeamFile | void,
|
||||
): Promise<TeamFile> {
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const teamFilePath = getTeamFilePath(teamName)
|
||||
const lockFilePath = `${teamFilePath}.lock`
|
||||
|
||||
await mkdir(teamDir, { recursive: true })
|
||||
try {
|
||||
await writeFile(teamFilePath, '{}', { encoding: 'utf-8', flag: 'wx' })
|
||||
} catch (e) {
|
||||
if (getErrnoCode(e) !== 'EEXIST') {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const release = await lockfile.lock(teamFilePath, {
|
||||
lockfilePath: lockFilePath,
|
||||
retries: {
|
||||
retries: 10,
|
||||
minTimeout: 5,
|
||||
maxTimeout: 100,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const current = await readTeamFileAsync(teamName)
|
||||
if (!current || !Array.isArray(current.members)) {
|
||||
throw new Error(`Team "${teamName}" does not exist`)
|
||||
}
|
||||
|
||||
const next = mutator(current) ?? current
|
||||
await writeFile(teamFilePath, jsonStringify(next, null, 2))
|
||||
return next
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a teammate from the team file by agent ID or name.
|
||||
* Used by the leader when processing shutdown approvals.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user