diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index 8ac2426..082118b 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -9,11 +9,23 @@ const SESSION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000 const MAX_PARENT_SESSIONS_TO_PARSE = 40 const ORPHAN_FALLBACK_WINDOW_MS = 15 * 60 * 1000 const SUBAGENT_MAX_ACTIVE_MS = 30 * 60 * 1000 +const SUBAGENT_ACTIVITY_EVENT_LIMIT = 6 +const SUBAGENT_ACTIVITY_TEXT_MAX_LEN = 80 + +type SessionsIndex = Record + +export interface SubagentActivityEvent { + key: string + text: string + at: number +} export interface SubagentInfo { toolId: string label: string sessionKey?: string + childSessionKey?: string + activityEvents?: SubagentActivityEvent[] } export interface AgentActivity { @@ -81,13 +93,198 @@ function parseRecordTimestamp(record: unknown): number { return 0 } -async function parseSubagentsFromSessionFile(filePath: string, sessionKey: string): Promise { +function normalizeActivityText(raw: unknown): string | null { + if (typeof raw !== 'string') return null + const compact = raw.replace(/\s+/g, ' ').trim() + if (!compact) return null + return compact.length > SUBAGENT_ACTIVITY_TEXT_MAX_LEN + ? `${compact.slice(0, SUBAGENT_ACTIVITY_TEXT_MAX_LEN - 1)}…` + : compact +} + +function extractChildSessionKeyFromPayload(payload: unknown): string | null { + if (!payload || typeof payload !== 'object') return null + const data = payload as Record + const direct = data.childSessionKey + if (typeof direct === 'string' && direct.includes(':subagent:')) return direct + + const details = data.details + if (details && typeof details === 'object') { + const fromDetails = (details as Record).childSessionKey + if (typeof fromDetails === 'string' && fromDetails.includes(':subagent:')) return fromDetails + } + return null +} + +function extractChildSessionKeyFromText(rawText: unknown): string | null { + if (typeof rawText !== 'string' || !rawText.trim()) return null + try { + const parsed = JSON.parse(rawText) + return extractChildSessionKeyFromPayload(parsed) + } catch { + const match = rawText.match(/agent:[^:\s]+:subagent:[a-f0-9-]+/i) + return match ? match[0] : null + } +} + +function extractChildSessionKeyFromToolResultMessage(message: unknown): string | null { + if (!message || typeof message !== 'object') return null + const msg = message as Record + const fromPayload = extractChildSessionKeyFromPayload(msg) + if (fromPayload) return fromPayload + + const content = msg.content + if (!Array.isArray(content)) return null + for (const block of content) { + if (!block || typeof block !== 'object') continue + const text = (block as Record).text + const fromText = extractChildSessionKeyFromText(text) + if (fromText) return fromText + } + return null +} + +function getSubagentSessionIdFromKey(sessionKey: string): string | null { + const idx = sessionKey.indexOf(':subagent:') + if (idx < 0) return null + const sessionId = sessionKey.slice(idx + ':subagent:'.length).trim() + return sessionId || null +} + +function resolveSubagentSessionId( + childSessionKey: string, + sessionsIndex?: SessionsIndex, +): string | null { + const fromIndex = sessionsIndex?.[childSessionKey]?.sessionId + if (typeof fromIndex === 'string' && fromIndex.trim()) return fromIndex.trim() + return getSubagentSessionIdFromKey(childSessionKey) +} + +async function parseSubagentActivityEvents( + agentSessionsDir: string, + childSessionKey: string, + sessionsIndex?: SessionsIndex, +): Promise { + const sessionId = resolveSubagentSessionId(childSessionKey, sessionsIndex) + if (!sessionId) return [] + const transcriptPath = path.join(agentSessionsDir, `${sessionId}.jsonl`) + if (!existsSync(transcriptPath)) return [] + + try { + const content = await fs.readFile(transcriptPath, 'utf8') + const lines = content.split('\n').filter(l => l.trim()) + const events: SubagentActivityEvent[] = [] + for (let i = 0; i < lines.length; i++) { + let record: any + try { + record = JSON.parse(lines[i]) + } catch { + continue + } + if (record?.type !== 'message' || !record?.message) continue + const at = parseRecordTimestamp(record) + const msg = record.message + const role = typeof msg.role === 'string' ? msg.role : '' + const blocks = Array.isArray(msg.content) ? msg.content : [] + if (role === 'assistant') { + for (let bi = 0; bi < blocks.length; bi++) { + const block = blocks[bi] + if (!block || typeof block !== 'object') continue + const b = block as Record + + if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string' && b.name) { + events.push({ + key: `${record.id || i}:tool:${b.id || bi}`, + text: `tool: ${b.name}`, + at, + }) + continue + } + + if (b.type === 'text') { + const normalized = normalizeActivityText(b.text) + if (!normalized) continue + events.push({ + key: `${record.id || i}:msg:${bi}`, + text: normalized, + at, + }) + } + } + continue + } + + if (role === 'toolResult') { + const toolName = typeof msg.toolName === 'string' ? msg.toolName.trim() : '' + const details = (msg.details && typeof msg.details === 'object') + ? (msg.details as Record) + : null + const status = typeof details?.status === 'string' ? details.status : '' + if (toolName) { + const statusTail = status ? ` (${status})` : '' + events.push({ + key: `${record.id || i}:result:${msg.toolCallId || ''}`, + text: `result: ${toolName}${statusTail}`, + at, + }) + } + for (let bi = 0; bi < blocks.length; bi++) { + const block = blocks[bi] + if (!block || typeof block !== 'object') continue + const normalized = normalizeActivityText((block as Record).text) + if (!normalized) continue + events.push({ + key: `${record.id || i}:result-text:${bi}`, + text: normalized, + at, + }) + } + continue + } + + if (role === 'user') { + for (let bi = 0; bi < blocks.length; bi++) { + const block = blocks[bi] + if (!block || typeof block !== 'object') continue + const normalized = normalizeActivityText((block as Record).text) + if (!normalized) continue + events.push({ + key: `${record.id || i}:user:${bi}`, + text: `task: ${normalized}`, + at, + }) + } + } + } + + events.sort((a, b) => a.at - b.at) + const deduped: SubagentActivityEvent[] = [] + const recentTextAt = new Map() + for (const event of events) { + const lastAt = recentTextAt.get(event.text) + // Skip near-duplicate text emitted within 1.5s from the same subagent timeline. + if (typeof lastAt === 'number' && Math.abs(event.at - lastAt) <= 1500) continue + recentTextAt.set(event.text, event.at) + deduped.push(event) + } + return deduped.slice(-SUBAGENT_ACTIVITY_EVENT_LIMIT) + } catch { + return [] + } +} + +async function parseSubagentsFromSessionFile( + agentSessionsDir: string, + filePath: string, + sessionKey: string, + sessionsIndex?: SessionsIndex, +): Promise { const subagents: SubagentInfo[] = [] try { const content = await fs.readFile(filePath, 'utf8') const lines = content.split('\n').filter(l => l.trim()) - const activeSubtasks = new Map() + const activeSubtasks = new Map() const spawnToolIds = new Set() for (const line of lines) { @@ -113,7 +310,15 @@ async function parseSubagentsFromSessionFile(filePath: string, sessionKey: strin if (record.type === 'user' && record.message?.content) { const blocks = Array.isArray(record.message.content) ? record.message.content : [] for (const block of blocks) { - if (block.type === 'tool_result' && block.tool_use_id && !spawnToolIds.has(block.tool_use_id)) { + if (block.type === 'tool_result' && block.tool_use_id) { + if (spawnToolIds.has(block.tool_use_id)) { + const childSessionKey = extractChildSessionKeyFromToolResultMessage(block) + if (childSessionKey && activeSubtasks.has(block.tool_use_id)) { + const prev = activeSubtasks.get(block.tool_use_id)! + activeSubtasks.set(block.tool_use_id, { ...prev, childSessionKey }) + } + continue + } activeSubtasks.delete(block.tool_use_id) } } @@ -140,6 +345,14 @@ async function parseSubagentsFromSessionFile(filePath: string, sessionKey: strin } else if (role === 'toolResult') { const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : '' const toolName = typeof msg.toolName === 'string' ? msg.toolName : '' + if (toolCallId && spawnToolIds.has(toolCallId)) { + const childSessionKey = extractChildSessionKeyFromToolResultMessage(msg) + if (childSessionKey && activeSubtasks.has(toolCallId)) { + const prev = activeSubtasks.get(toolCallId)! + activeSubtasks.set(toolCallId, { ...prev, childSessionKey }) + } + continue + } if (toolCallId && !isSpawnTool(toolName) && !spawnToolIds.has(toolCallId)) { activeSubtasks.delete(toolCallId) } @@ -167,7 +380,17 @@ async function parseSubagentsFromSessionFile(filePath: string, sessionKey: strin for (const [toolId, state] of activeSubtasks.entries()) { if (state.at > 0 && now - state.at > SUBAGENT_MAX_ACTIVE_MS) continue const label = state.label - subagents.push({ toolId, label, sessionKey }) + let activityEvents: SubagentActivityEvent[] | undefined + if (state.childSessionKey) { + activityEvents = await parseSubagentActivityEvents(agentSessionsDir, state.childSessionKey, sessionsIndex) + } + subagents.push({ + toolId, + label, + sessionKey, + childSessionKey: state.childSessionKey, + activityEvents: activityEvents && activityEvents.length > 0 ? activityEvents : undefined, + }) } } catch { // Ignore parse errors @@ -183,11 +406,12 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis const sessionFiles: Array<{ sessionKey: string; filePath: string; updatedAt: number }> = [] const knownFilePaths = new Set() const subagentSessionIds = new Set() + let sessionsIndex: SessionsIndex = {} const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json') if (existsSync(sessionsIndexPath)) { try { const sessionsIndexRaw = await fs.readFile(sessionsIndexPath, 'utf8') - const sessionsIndex = JSON.parse(sessionsIndexRaw) as Record + sessionsIndex = JSON.parse(sessionsIndexRaw) as SessionsIndex for (const [sessionKey, meta] of Object.entries(sessionsIndex)) { if (!meta || typeof meta.sessionId !== 'string' || !meta.sessionId) continue if (sessionKey.includes(':subagent:')) { @@ -242,7 +466,7 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis sessionFiles.sort((a, b) => b.updatedAt - a.updatedAt) const candidates = sessionFiles.slice(0, MAX_PARENT_SESSIONS_TO_PARSE) - const nested = await Promise.all(candidates.map((s) => parseSubagentsFromSessionFile(s.filePath, s.sessionKey))) + const nested = await Promise.all(candidates.map((s) => parseSubagentsFromSessionFile(agentSessionsDir, s.filePath, s.sessionKey, sessionsIndex))) const dedupe = new Set() for (const list of nested) { for (const sub of list) { diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 813e72f..67c9264 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -215,6 +215,7 @@ export default function PixelOfficePage() { const savedLayoutRef = useRef(cachedSavedLayout) const animationFrameIdRef = useRef(null) const prevAgentStatesRef = useRef>(new Map(cachedPrevAgentStates)) + const seenSubagentEventKeysRef = useRef>(new Map()) const [agents, setAgents] = useState(cachedAgents) const [hoveredAgentId, setHoveredAgentId] = useState(null) @@ -604,7 +605,8 @@ export default function PixelOfficePage() { ch.systemRoleType === 'gateway_sre' && ch.systemStatus === 'down' && ch.state === 'idle' - if (!workingCharIds.has(ch.id) && !isSreBlackword) continue + const hasInjectedSnippet = ch.codeSnippets.length > 0 + if (!workingCharIds.has(ch.id) && !isSreBlackword && !hasInjectedSnippet) continue if (ch.codeSnippets.length === 0) continue const anchorX = ox + ch.x * zoom const anchorY = containerTop + oy + (ch.y - (isSreBlackword ? 24 : 10)) * zoom @@ -712,10 +714,47 @@ export default function PixelOfficePage() { setAgents(newAgents) cachedAgents = newAgents - if (officeRef.current) { - syncAgentsToOffice(newAgents, officeRef.current, agentIdMapRef.current, nextIdRef.current) + const office = officeRef.current + if (office) { + syncAgentsToOffice(newAgents, office, agentIdMapRef.current, nextIdRef.current) cachedAgentIdMap = new Map(agentIdMapRef.current) cachedNextCharacterId = nextIdRef.current.current + + const seen = seenSubagentEventKeysRef.current + const now = Date.now() + for (const [key, ts] of seen.entries()) { + if (now - ts > 24 * 60 * 60 * 1000) seen.delete(key) + } + for (const agent of newAgents) { + const parentCharId = agentIdMapRef.current.get(agent.agentId) + if (typeof parentCharId !== 'number') continue + if (!agent.subagents?.length) continue + for (const sub of agent.subagents) { + if (!sub.activityEvents?.length) continue + const subKey = sub.sessionKey ? `${sub.sessionKey}::${sub.toolId}` : sub.toolId + const subCharId = office.getSubagentId(parentCharId, subKey) + if (subCharId == null) continue + + const orderedEvents = (sub.activityEvents || []).slice().sort((a, b) => a.at - b.at) + let emittedNew = false + for (const event of orderedEvents) { + const uniq = `${agent.agentId}:${subKey}:${event.key}` + if (seen.has(uniq)) continue + seen.set(uniq, now) + office.pushCodeSnippet(subCharId, event.text) + emittedNew = true + } + + // Fallback: if no parsed activity events yet, show subagent task label once. + if (!emittedNew && orderedEvents.length === 0 && sub.label) { + const fallbackUniq = `${agent.agentId}:${subKey}:label` + if (!seen.has(fallbackUniq)) { + seen.set(fallbackUniq, now) + office.pushCodeSnippet(subCharId, `task: ${sub.label}`) + } + } + } + } } // Play sound when agent transitions to waiting diff --git a/lib/pixel-office/agentBridge.ts b/lib/pixel-office/agentBridge.ts index 0a715ef..d1c9ae1 100644 --- a/lib/pixel-office/agentBridge.ts +++ b/lib/pixel-office/agentBridge.ts @@ -4,6 +4,8 @@ export interface SubagentInfo { toolId: string label: string sessionKey?: string + childSessionKey?: string + activityEvents?: Array<{ key: string; text: string; at: number }> } export interface AgentActivity { diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 36a2d94..457dab1 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1222,6 +1222,23 @@ export class OfficeState { } } + /** Push an explicit snippet bubble (e.g. subagent session updates). */ + pushCodeSnippet(id: number, text: string): void { + const ch = this.characters.get(id) + if (!ch || ch.isCat || ch.isLobster) return + const compact = text.replace(/\s+/g, ' ').trim() + if (!compact) return + ch.codeSnippets.push({ + text: compact.length > 90 ? `${compact.slice(0, 89)}…` : compact, + age: 0, + x: (Math.random() - 0.5) * 20, + y: 0, + }) + if (ch.codeSnippets.length > 4) { + ch.codeSnippets = ch.codeSnippets.slice(-4) + } + } + /** Dismiss bubble on click — permission: instant, waiting: quick fade */ dismissBubble(id: number): void { const ch = this.characters.get(id) @@ -1324,9 +1341,13 @@ export class OfficeState { // Code snippet particles: // - Working agents: regular coding snippets // - Gateway SRE in down state: ops slang ("运维黑话") - if (isSreFirefighting && !ch.isCat && !ch.isLobster) { + // - Subagent/session-driven snippets can be injected externally via pushCodeSnippet(). + if (!ch.isCat && !ch.isLobster) { for (const s of ch.codeSnippets) s.age += dt ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME) + } + + if (isSreFirefighting && !ch.isCat && !ch.isLobster) { if (ch.codeSnippets.length < 3 && Math.random() < dt * SRE_BLACKWORD_SPAWN_RATE) { ch.codeSnippets.push({ text: SRE_BLACKWORDS[Math.floor(Math.random() * SRE_BLACKWORDS.length)], @@ -1335,10 +1356,7 @@ export class OfficeState { y: 0, }) } - } else if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster) { - // Age existing snippets and remove expired ones - for (const s of ch.codeSnippets) s.age += dt - ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME) + } else if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster && !ch.isSubagent) { // Spawn new snippet randomly if (ch.codeSnippets.length < 2 && Math.random() < dt * CODE_SNIPPET_SPAWN_RATE) { ch.codeSnippets.push({ @@ -1348,8 +1366,6 @@ export class OfficeState { y: 0, }) } - } else { - ch.codeSnippets = [] } } // Remove characters that finished despawn