mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-08-14 00:47:49 +00:00
1164 lines
45 KiB
TypeScript
1164 lines
45 KiB
TypeScript
import { NextResponse } from 'next/server'
|
||
import { promises as fs, existsSync } from 'fs'
|
||
import path from 'path'
|
||
import { parseJsonText } from '@/lib/json'
|
||
import { OPENCLAW_AGENTS_DIR, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from '@/lib/openclaw-paths'
|
||
|
||
export const dynamic = 'force-dynamic'
|
||
export const revalidate = 0
|
||
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 = 10 * 60 * 1000
|
||
const SUBAGENT_ACTIVITY_EVENT_LIMIT = 6
|
||
const SUBAGENT_ACTIVITY_TEXT_MAX_LEN = 80
|
||
|
||
function normalizeIdentityValue(rawValue: string): string | null {
|
||
let value = rawValue.trim()
|
||
if (!value) return null
|
||
|
||
const placeholder = /^[_*`~\s]*\((?:your|pick|choose|fill|todo|tbd|填写|选择|待)[^)]*\)[_*`~\s]*/iu
|
||
while (placeholder.test(value)) {
|
||
value = value.replace(placeholder, '').replace(/^[-::,,\s]+/, '').trim()
|
||
}
|
||
if (!value) return null
|
||
|
||
const wrapped = value.match(/^([_*`])(.+)\1$/)
|
||
if (wrapped) value = wrapped[2].trim()
|
||
|
||
return value || null
|
||
}
|
||
|
||
function isEmojiLike(value: string): boolean {
|
||
return /\p{Extended_Pictographic}/u.test(value) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(value)
|
||
}
|
||
|
||
function normalizeIdentityEmoji(rawEmoji: unknown): string | null {
|
||
if (typeof rawEmoji !== 'string') return null
|
||
const emoji = normalizeIdentityValue(rawEmoji)
|
||
if (!emoji || !isEmojiLike(emoji)) return null
|
||
return emoji
|
||
}
|
||
|
||
type SessionsIndex = Record<string, { sessionId?: string; updatedAt?: number }>
|
||
type CronStoreJob = {
|
||
id: string
|
||
agentId?: string
|
||
sessionKey?: string
|
||
name?: string
|
||
enabled?: boolean
|
||
payload?: { kind?: string; message?: string; text?: string }
|
||
state?: {
|
||
nextRunAtMs?: number
|
||
lastRunAtMs?: number
|
||
lastDurationMs?: number
|
||
lastStatus?: string
|
||
lastError?: string
|
||
consecutiveErrors?: number
|
||
}
|
||
}
|
||
|
||
export interface SubagentActivityEvent {
|
||
key: string
|
||
text: string
|
||
at: number
|
||
}
|
||
|
||
export interface SubagentInfo {
|
||
toolId: string
|
||
label: string
|
||
sessionKey?: string
|
||
childSessionKey?: string
|
||
activityEvents?: SubagentActivityEvent[]
|
||
}
|
||
|
||
export interface CronJobInfo {
|
||
key: string
|
||
jobId: string
|
||
label: string
|
||
isRunning: boolean
|
||
lastRunAt: number
|
||
nextRunAt?: number
|
||
durationMs?: number
|
||
lastStatus: 'success' | 'running' | 'failed'
|
||
lastSummary?: string
|
||
consecutiveFailures: number
|
||
}
|
||
|
||
export interface AgentActivity {
|
||
agentId: string
|
||
name: string
|
||
emoji: string
|
||
state: 'idle' | 'working' | 'waiting' | 'offline'
|
||
currentTool?: string
|
||
toolStatus?: string
|
||
lastActive: number
|
||
subagents?: SubagentInfo[]
|
||
cronJobs?: CronJobInfo[]
|
||
lastTask?: string
|
||
}
|
||
|
||
type AgentConfigEntry = {
|
||
id: string
|
||
name?: string
|
||
emoji?: string
|
||
identity?: { emoji?: string }
|
||
}
|
||
|
||
async function loadAgentList(config: any, agentsDir: string): Promise<AgentConfigEntry[]> {
|
||
const configured = Array.isArray(config?.agents?.list)
|
||
? config.agents.list.filter((agent: any) => agent && typeof agent.id === 'string' && agent.id)
|
||
: []
|
||
if (configured.length > 0) return configured
|
||
|
||
try {
|
||
if (!existsSync(agentsDir)) return []
|
||
const dirs = await fs.readdir(agentsDir, { withFileTypes: true })
|
||
return dirs
|
||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
||
.map((entry) => ({ id: entry.name }))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function isSubtaskDescription(desc: string): boolean {
|
||
const d = desc.toLowerCase()
|
||
return desc.startsWith('Subtask:') || desc.startsWith('子任务') || d.includes('subtask')
|
||
}
|
||
|
||
function isSpawnTool(name: string): boolean {
|
||
return name === 'sessions_spawn' || name === 'session_spawn'
|
||
}
|
||
|
||
function pickSubagentLabel(raw: unknown): string {
|
||
if (!raw || typeof raw !== 'object') return 'Subtask'
|
||
const args = raw as Record<string, unknown>
|
||
if (typeof args.label === 'string' && args.label.trim()) return args.label.trim()
|
||
if (typeof args.task === 'string' && args.task.trim()) return args.task.trim()
|
||
if (typeof args.description === 'string' && args.description.trim()) return args.description.trim()
|
||
return 'Subtask'
|
||
}
|
||
|
||
function extractCompletedSubagentLabel(text: string): string | null {
|
||
if (!text) return null
|
||
const patterns = [
|
||
/A subagent task\s+”([^”]+)”\s+just completed/i,
|
||
/A subagent task\s+'([^']+)'\s+just completed/i,
|
||
/subagent task\s+”([^”]+)”\s+.*completed/i,
|
||
/subagent task\s+'([^']+)'\s+.*completed/i,
|
||
/子任务[“”]([^””]+)[“”].{0,12}完成/,
|
||
]
|
||
for (const p of patterns) {
|
||
const m = text.match(p)
|
||
if (m?.[1]?.trim()) return m[1].trim()
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Extract agentId from “✅ Subagent {agentId} finished” pattern in assistant messages.
|
||
* e.g. “✅ Subagent agentxq finished” → “agentxq”
|
||
*/
|
||
function extractFinishedSubagentId(text: string): string | null {
|
||
if (!text) return null
|
||
const m = text.match(/(?:✅|☑️|✓)\s*Subagent\s+(\S+)\s+finished/i)
|
||
return m?.[1]?.trim() ?? null
|
||
}
|
||
|
||
/**
|
||
* Given a finished agentId, remove matching subtasks from activeSubtasks.
|
||
* Matches on childSessionKey containing “:agentId:” (e.g. “agent:agentxq:subagent:xxx”).
|
||
*/
|
||
function removeFinishedSubagentById(
|
||
agentId: string,
|
||
activeSubtasks: Map<string, { label: string; at: number; childSessionKey?: string }>,
|
||
): void {
|
||
for (const [id, state] of activeSubtasks.entries()) {
|
||
if (state.childSessionKey && state.childSessionKey.includes(`:${agentId}:`)) {
|
||
activeSubtasks.delete(id)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
function parseRecordTimestamp(record: unknown): number {
|
||
if (!record || typeof record !== 'object') return 0
|
||
const rec = record as Record<string, unknown>
|
||
if (typeof rec.timestamp === 'string') {
|
||
const t = Date.parse(rec.timestamp)
|
||
if (Number.isFinite(t)) return t
|
||
}
|
||
if (typeof rec.timestamp === 'number' && Number.isFinite(rec.timestamp)) return rec.timestamp
|
||
const msg = rec.message
|
||
if (msg && typeof msg === 'object') {
|
||
const m = msg as Record<string, unknown>
|
||
if (typeof m.timestamp === 'string') {
|
||
const t = Date.parse(m.timestamp)
|
||
if (Number.isFinite(t)) return t
|
||
}
|
||
if (typeof m.timestamp === 'number' && Number.isFinite(m.timestamp)) return m.timestamp
|
||
}
|
||
return 0
|
||
}
|
||
|
||
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 normalizeCronLabel(raw: unknown, fallbackKey: string): string {
|
||
if (typeof raw === 'string' && raw.trim()) {
|
||
return raw.replace(/^Cron:\s*/i, '').trim() || fallbackKey
|
||
}
|
||
return fallbackKey
|
||
}
|
||
|
||
function truncateSummary(raw: string, maxLen = 120): string {
|
||
const compact = raw.replace(/\s+/g, ' ').trim()
|
||
if (!compact) return ''
|
||
return compact.length > maxLen ? `${compact.slice(0, maxLen - 1)}…` : compact
|
||
}
|
||
|
||
function resolveCronStorePath(config: any): string {
|
||
const raw = typeof config?.cron?.store === 'string' ? config.cron.store.trim() : ''
|
||
if (!raw) return path.join(OPENCLAW_HOME, 'cron', 'jobs.json')
|
||
if (raw.startsWith('~')) return path.join(process.env.HOME || '', raw.slice(1))
|
||
return path.resolve(raw)
|
||
}
|
||
|
||
async function loadCronJobs(config: any): Promise<CronStoreJob[]> {
|
||
const storePath = resolveCronStorePath(config)
|
||
if (!existsSync(storePath)) return []
|
||
try {
|
||
const raw = await fs.readFile(storePath, 'utf8')
|
||
const parsed = JSON.parse(raw)
|
||
return Array.isArray(parsed?.jobs) ? parsed.jobs.filter(Boolean) : []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function inferCronOwnerAgentId(job: CronStoreJob): string {
|
||
if (typeof job.agentId === 'string' && job.agentId.trim()) return job.agentId.trim()
|
||
if (typeof job.sessionKey === 'string' && job.sessionKey.startsWith('agent:')) {
|
||
const parts = job.sessionKey.split(':')
|
||
if (parts[1]?.trim()) return parts[1].trim()
|
||
}
|
||
return 'main'
|
||
}
|
||
|
||
function deriveCronSummaryFromJob(job: CronStoreJob): string | undefined {
|
||
const lastError = typeof job.state?.lastError === 'string' ? truncateSummary(job.state.lastError) : ''
|
||
if (lastError) return lastError
|
||
const payloadText =
|
||
typeof job.payload?.message === 'string'
|
||
? job.payload.message
|
||
: typeof job.payload?.text === 'string'
|
||
? job.payload.text
|
||
: ''
|
||
return payloadText ? truncateSummary(payloadText) : undefined
|
||
}
|
||
|
||
function mapCronStatus(status: string | undefined): 'success' | 'running' | 'failed' {
|
||
const normalized = (status || '').trim().toLowerCase()
|
||
if (normalized === 'error' || normalized === 'failed') return 'failed'
|
||
if (normalized === 'running') return 'running'
|
||
return 'success'
|
||
}
|
||
|
||
function inferCronStatusFromTranscript(lines: string[], updatedAt: number): {
|
||
isRunning: boolean
|
||
lastStatus: 'success' | 'running' | 'failed'
|
||
lastSummary?: string
|
||
durationMs?: number
|
||
} {
|
||
let lastAssistantText: string | undefined
|
||
let lastAssistantError: string | undefined
|
||
let lastToolError: string | undefined
|
||
let lastToolText: string | undefined
|
||
let sawTerminalAssistant = false
|
||
let sawToolUseWithoutResult = false
|
||
let firstAt = 0
|
||
let lastAt = 0
|
||
|
||
for (const line of lines) {
|
||
let record: any
|
||
try {
|
||
record = JSON.parse(line)
|
||
} catch {
|
||
continue
|
||
}
|
||
const msg = record?.message
|
||
if (!msg || typeof msg !== 'object') continue
|
||
const at = parseRecordTimestamp(record)
|
||
if (at > 0 && (firstAt === 0 || at < firstAt)) firstAt = at
|
||
if (at > lastAt) lastAt = at
|
||
|
||
if (record.type === 'message') {
|
||
const role = typeof msg.role === 'string' ? msg.role : ''
|
||
const blocks = Array.isArray(msg.content) ? msg.content : []
|
||
|
||
if (role === 'assistant') {
|
||
for (const block of blocks) {
|
||
if (!block || typeof block !== 'object') continue
|
||
const b = block as Record<string, unknown>
|
||
if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string') {
|
||
sawToolUseWithoutResult = true
|
||
}
|
||
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) {
|
||
lastAssistantText = truncateSummary(b.text)
|
||
}
|
||
}
|
||
if (typeof msg.errorMessage === 'string' && msg.errorMessage.trim()) {
|
||
lastAssistantError = truncateSummary(msg.errorMessage)
|
||
}
|
||
const stopReason = typeof msg.stopReason === 'string' ? msg.stopReason : ''
|
||
if (stopReason === 'stop' || stopReason === 'error') {
|
||
sawTerminalAssistant = true
|
||
}
|
||
}
|
||
|
||
if (role === 'toolResult') {
|
||
const details = isPlainObject(msg.details) ? msg.details : null
|
||
const status = typeof details?.status === 'string' ? details.status : ''
|
||
if (status.toLowerCase() === 'error' || msg.isError === true) {
|
||
const detailError =
|
||
typeof details?.error === 'string' && details.error.trim()
|
||
? details.error
|
||
: typeof details?.aggregated === 'string' && details.aggregated.trim()
|
||
? details.aggregated
|
||
: ''
|
||
if (detailError) lastToolError = truncateSummary(detailError)
|
||
} else {
|
||
const detailText =
|
||
typeof details?.aggregated === 'string' && details.aggregated.trim()
|
||
? details.aggregated
|
||
: Array.isArray(msg.content)
|
||
? msg.content
|
||
.map((block: any) => (block?.type === 'text' && typeof block.text === 'string') ? block.text : '')
|
||
.join(' ')
|
||
: ''
|
||
if (detailText.trim()) lastToolText = truncateSummary(detailText)
|
||
}
|
||
sawToolUseWithoutResult = false
|
||
}
|
||
}
|
||
}
|
||
|
||
if (lastAssistantError || lastToolError) {
|
||
return {
|
||
isRunning: false,
|
||
lastStatus: 'failed',
|
||
lastSummary: lastAssistantError || lastToolError,
|
||
durationMs: firstAt > 0 && lastAt >= firstAt ? lastAt - firstAt : undefined,
|
||
}
|
||
}
|
||
|
||
if (sawTerminalAssistant && lastAssistantText) {
|
||
return {
|
||
isRunning: false,
|
||
lastStatus: 'success',
|
||
lastSummary: lastAssistantText,
|
||
durationMs: firstAt > 0 && lastAt >= firstAt ? lastAt - firstAt : undefined,
|
||
}
|
||
}
|
||
|
||
const recentlyUpdated = updatedAt > 0 && Date.now() - updatedAt <= 2 * 60 * 1000
|
||
if (sawToolUseWithoutResult || recentlyUpdated) {
|
||
return {
|
||
isRunning: true,
|
||
lastStatus: 'running',
|
||
lastSummary: lastAssistantText || lastToolText,
|
||
durationMs: firstAt > 0 ? Math.max(0, Date.now() - firstAt) : undefined,
|
||
}
|
||
}
|
||
|
||
return {
|
||
isRunning: false,
|
||
lastStatus: lastToolText ? 'success' : 'running',
|
||
lastSummary: lastAssistantText || lastToolText,
|
||
durationMs: firstAt > 0 && lastAt >= firstAt ? lastAt - firstAt : undefined,
|
||
}
|
||
}
|
||
|
||
function isPlainObject(value: unknown): value is Record<string, any> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||
}
|
||
|
||
function extractChildSessionKeyFromPayload(payload: unknown): string | null {
|
||
if (!payload || typeof payload !== 'object') return null
|
||
const data = payload as Record<string, unknown>
|
||
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<string, unknown>).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<string, unknown>
|
||
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<string, unknown>).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<SubagentActivityEvent[]> {
|
||
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<string, unknown>
|
||
|
||
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<string, unknown>)
|
||
: 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<string, unknown>).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<string, unknown>).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<string, number>()
|
||
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<SubagentInfo[]> {
|
||
const subagents: SubagentInfo[] = []
|
||
try {
|
||
const content = await fs.readFile(filePath, 'utf8')
|
||
const lines = content.split('\n').filter(l => l.trim())
|
||
|
||
const activeSubtasks = new Map<string, { label: string; at: number; childSessionKey?: string; acceptedAt?: number }>()
|
||
const spawnToolIds = new Set<string>()
|
||
/** toolIds whose spawn was accepted and are awaiting a text response from parent */
|
||
const pendingResponseIds = new Set<string>()
|
||
|
||
for (const line of lines) {
|
||
try {
|
||
const record = JSON.parse(line)
|
||
const eventAt = parseRecordTimestamp(record)
|
||
|
||
// Legacy format
|
||
if (record.type === 'assistant' && record.message?.content) {
|
||
const blocks = Array.isArray(record.message.content) ? record.message.content : []
|
||
for (const block of blocks) {
|
||
if (block.type === 'text' && typeof block.text === 'string') {
|
||
const finishedId = extractFinishedSubagentId(block.text)
|
||
if (finishedId) { removeFinishedSubagentById(finishedId, activeSubtasks); pendingResponseIds.clear() }
|
||
if (pendingResponseIds.size > 0) {
|
||
for (const id of pendingResponseIds) activeSubtasks.delete(id)
|
||
pendingResponseIds.clear()
|
||
}
|
||
continue
|
||
}
|
||
if (block.type !== 'tool_use' || typeof block.id !== 'string' || !block.id) continue
|
||
if (typeof block.name === 'string' && isSpawnTool(block.name)) {
|
||
activeSubtasks.set(block.id, { label: pickSubagentLabel(block.input), at: eventAt })
|
||
spawnToolIds.add(block.id)
|
||
continue
|
||
}
|
||
if (typeof block.input?.description === 'string' && isSubtaskDescription(block.input.description)) {
|
||
activeSubtasks.set(block.id, { label: block.input.description, at: eventAt })
|
||
}
|
||
}
|
||
}
|
||
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) {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// New format
|
||
if (record.type === 'message' && record.message) {
|
||
const msg = record.message
|
||
const role = typeof msg.role === 'string' ? msg.role : ''
|
||
const blocks = Array.isArray(msg.content) ? msg.content : []
|
||
if (role === 'assistant') {
|
||
for (const block of blocks) {
|
||
// Check text blocks for completion signals
|
||
if (block?.type === 'text' && typeof block.text === 'string') {
|
||
// Pattern 1: "✅ Subagent {agentId} finished"
|
||
const finishedId = extractFinishedSubagentId(block.text)
|
||
if (finishedId) { removeFinishedSubagentById(finishedId, activeSubtasks); pendingResponseIds.clear() }
|
||
// Pattern 2: any assistant text reply clears spawns that were awaiting a response
|
||
// (handles custom responses like "✅ 已叫起 agentdev!", "起來了!", etc.)
|
||
if (pendingResponseIds.size > 0) {
|
||
for (const id of pendingResponseIds) activeSubtasks.delete(id)
|
||
pendingResponseIds.clear()
|
||
}
|
||
continue
|
||
}
|
||
if (block?.type === 'toolCall' && typeof block.id === 'string' && block.id) {
|
||
if (typeof block.name === 'string' && isSpawnTool(block.name)) {
|
||
activeSubtasks.set(block.id, { label: pickSubagentLabel(block.arguments), at: eventAt })
|
||
spawnToolIds.add(block.id)
|
||
} else if (typeof block.arguments?.description === 'string' && isSubtaskDescription(block.arguments.description)) {
|
||
activeSubtasks.set(block.id, { label: block.arguments.description, at: eventAt })
|
||
}
|
||
} else if (block?.type === 'tool_use' && typeof block.id === 'string' && typeof block.input?.description === 'string') {
|
||
if (isSubtaskDescription(block.input.description)) activeSubtasks.set(block.id, { label: block.input.description, at: eventAt })
|
||
}
|
||
}
|
||
} 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 (activeSubtasks.has(toolCallId)) {
|
||
const prev = activeSubtasks.get(toolCallId)!
|
||
activeSubtasks.set(toolCallId, { ...prev, childSessionKey: childSessionKey || prev.childSessionKey, acceptedAt: eventAt })
|
||
pendingResponseIds.add(toolCallId)
|
||
}
|
||
continue
|
||
}
|
||
if (toolCallId && !isSpawnTool(toolName) && !spawnToolIds.has(toolCallId)) {
|
||
activeSubtasks.delete(toolCallId)
|
||
}
|
||
} else if (role === 'user') {
|
||
const text = blocks
|
||
.map((b: { type?: string; text?: string }) => (b?.type === 'text' && typeof b.text === 'string') ? b.text : '')
|
||
.join('\n')
|
||
const completedLabel = extractCompletedSubagentLabel(text)
|
||
if (completedLabel) {
|
||
for (const [id, state] of activeSubtasks.entries()) {
|
||
if (state.label === completedLabel || state.label.includes(completedLabel) || completedLabel.includes(state.label)) {
|
||
activeSubtasks.delete(id)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Skip bad line
|
||
}
|
||
}
|
||
|
||
const now = Date.now()
|
||
const SPAWN_ACCEPTED_TIMEOUT_MS = 3 * 60 * 1000 // 3 min after spawn accepted — fallback
|
||
for (const [toolId, state] of activeSubtasks.entries()) {
|
||
if (state.at > 0 && now - state.at > SUBAGENT_MAX_ACTIVE_MS) continue
|
||
if (state.acceptedAt && now - state.acceptedAt > SPAWN_ACCEPTED_TIMEOUT_MS) continue
|
||
const label = state.label
|
||
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
|
||
}
|
||
return subagents
|
||
}
|
||
|
||
/** Parse subagents from all parent sessions (main/direct/group/openai/cron etc.), grouped by session */
|
||
async function parseSubagents(agentSessionsDir: string, agentId: string): Promise<SubagentInfo[]> {
|
||
const allSubagents: SubagentInfo[] = []
|
||
try {
|
||
const cutoff = Date.now() - SESSION_LOOKBACK_MS
|
||
const sessionFiles: Array<{ sessionKey: string; filePath: string; updatedAt: number }> = []
|
||
const knownFilePaths = new Set<string>()
|
||
const subagentSessionIds = new Set<string>()
|
||
let sessionsIndex: SessionsIndex = {}
|
||
const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json')
|
||
if (existsSync(sessionsIndexPath)) {
|
||
try {
|
||
const sessionsIndexRaw = await fs.readFile(sessionsIndexPath, 'utf8')
|
||
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:')) {
|
||
subagentSessionIds.add(meta.sessionId)
|
||
continue
|
||
}
|
||
const filePath = path.join(agentSessionsDir, `${meta.sessionId}.jsonl`)
|
||
if (!existsSync(filePath)) continue
|
||
let updatedAt = 0
|
||
if (typeof meta.updatedAt === 'number' && meta.updatedAt > 0) {
|
||
updatedAt = meta.updatedAt
|
||
} else {
|
||
try {
|
||
const stat = await fs.stat(filePath)
|
||
updatedAt = stat.mtimeMs
|
||
} catch {
|
||
updatedAt = 0
|
||
}
|
||
}
|
||
if (updatedAt > 0 && updatedAt < cutoff) continue
|
||
sessionFiles.push({ sessionKey, filePath, updatedAt })
|
||
knownFilePaths.add(filePath)
|
||
}
|
||
} catch {
|
||
// Ignore index parse errors
|
||
}
|
||
}
|
||
|
||
// Fallback: include recent parent session files that are missing in sessions.json mapping.
|
||
try {
|
||
const orphanCutoff = Date.now() - ORPHAN_FALLBACK_WINDOW_MS
|
||
const files = await fs.readdir(agentSessionsDir)
|
||
for (const file of files) {
|
||
if (!file.endsWith('.jsonl')) continue
|
||
if (file.startsWith('probe-')) continue
|
||
const filePath = path.join(agentSessionsDir, file)
|
||
if (knownFilePaths.has(filePath)) continue
|
||
const sessionId = file.slice(0, -'.jsonl'.length)
|
||
if (subagentSessionIds.has(sessionId)) continue
|
||
const stat = await fs.stat(filePath)
|
||
if (stat.mtimeMs < orphanCutoff) continue
|
||
if (stat.mtimeMs < cutoff) continue
|
||
sessionFiles.push({
|
||
sessionKey: `agent:${agentId}:orphan:${sessionId}`,
|
||
filePath,
|
||
updatedAt: stat.mtimeMs,
|
||
})
|
||
}
|
||
} catch {
|
||
// Ignore fallback scan errors
|
||
}
|
||
|
||
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(agentSessionsDir, s.filePath, s.sessionKey, sessionsIndex)))
|
||
const dedupe = new Set<string>()
|
||
for (const list of nested) {
|
||
for (const sub of list) {
|
||
const key = `${sub.sessionKey || ''}::${sub.toolId}`
|
||
if (dedupe.has(key)) continue
|
||
dedupe.add(key)
|
||
allSubagents.push(sub)
|
||
}
|
||
}
|
||
} catch {
|
||
// Ignore parse errors
|
||
}
|
||
return allSubagents
|
||
}
|
||
|
||
async function parseCronJobs(agentSessionsDir: string, cronJobsForAgent: CronStoreJob[]): Promise<CronJobInfo[]> {
|
||
if (cronJobsForAgent.length === 0) return []
|
||
|
||
const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json')
|
||
const sessionsIndex = existsSync(sessionsIndexPath)
|
||
? JSON.parse(await fs.readFile(sessionsIndexPath, 'utf8')) as Record<string, any>
|
||
: {}
|
||
|
||
const cronJobs: CronJobInfo[] = []
|
||
|
||
for (const job of cronJobsForAgent) {
|
||
const entries = Object.entries(sessionsIndex)
|
||
.filter(([sessionKey, meta]) => sessionKey.includes(`:cron:${job.id}`) && meta && typeof (meta as any).sessionId === 'string')
|
||
.map(([sessionKey, meta]) => ({
|
||
sessionKey,
|
||
sessionId: (meta as any).sessionId as string,
|
||
updatedAt: typeof (meta as any).updatedAt === 'number' ? (meta as any).updatedAt : 0,
|
||
label: typeof (meta as any).label === 'string' ? (meta as any).label : undefined,
|
||
}))
|
||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||
|
||
const latest = entries[0]
|
||
let transcriptStatus: ReturnType<typeof inferCronStatusFromTranscript> | null = null
|
||
|
||
if (latest) {
|
||
const transcriptPath = path.join(agentSessionsDir, `${latest.sessionId}.jsonl`)
|
||
if (existsSync(transcriptPath)) {
|
||
const transcript = await fs.readFile(transcriptPath, 'utf8')
|
||
transcriptStatus = inferCronStatusFromTranscript(transcript.split('\n').filter((line) => line.trim()), latest.updatedAt)
|
||
}
|
||
}
|
||
|
||
const fallbackLabel = latest?.sessionKey?.split(':cron:')[1] || job.id
|
||
const state = job.state || {}
|
||
cronJobs.push({
|
||
key: latest?.sessionKey?.includes(':run:') ? latest.sessionKey.split(':run:')[0] : latest?.sessionKey || `agent:${inferCronOwnerAgentId(job)}:cron:${job.id}`,
|
||
jobId: job.id,
|
||
label: normalizeCronLabel(job.name || latest?.label, fallbackLabel),
|
||
isRunning: transcriptStatus?.isRunning || mapCronStatus(state.lastStatus) === 'running',
|
||
lastRunAt: typeof state.lastRunAtMs === 'number' ? state.lastRunAtMs : latest?.updatedAt || 0,
|
||
nextRunAt: typeof state.nextRunAtMs === 'number' ? state.nextRunAtMs : undefined,
|
||
durationMs: typeof state.lastDurationMs === 'number' ? state.lastDurationMs : transcriptStatus?.durationMs,
|
||
lastStatus: transcriptStatus?.lastStatus || mapCronStatus(state.lastStatus),
|
||
lastSummary: transcriptStatus?.lastSummary || deriveCronSummaryFromJob(job),
|
||
consecutiveFailures: typeof state.consecutiveErrors === 'number' ? state.consecutiveErrors : 0,
|
||
})
|
||
}
|
||
|
||
cronJobs.sort((a, b) => b.lastRunAt - a.lastRunAt)
|
||
return cronJobs
|
||
}
|
||
|
||
|
||
/**
|
||
* Extract the actual user-typed text from a session message block.
|
||
*
|
||
* Handles three formats:
|
||
* 1. Subagent spawn: contains "[Subagent Task]: ..." — extract what follows the label
|
||
* 2. Channel message (Telegram etc.): injected "Conversation info" + "Sender" code fences
|
||
* before the real text — extract what comes after the last ``` fence
|
||
* 3. Fallback: strip XML context blocks and leading timestamp
|
||
*/
|
||
function extractUserText(rawText: string): string | null {
|
||
// Strip XML context injections first (relevant-memories etc.)
|
||
const noXml = rawText.replace(/<[a-z][\s\S]*?<\/[a-z][^>]*>/gi, '')
|
||
|
||
// Strategy 1: subagent task — "[Subagent Task]: ..."
|
||
const subagentMatch = noXml.match(/\[Subagent Task\]:\s*([\s\S]+)/)
|
||
if (subagentMatch) {
|
||
return subagentMatch[1].replace(/\s+/g, ' ').trim().slice(0, 500)
|
||
}
|
||
|
||
// Strategy 2: channel message — text after last ``` fence
|
||
const lastFence = rawText.lastIndexOf('```')
|
||
if (lastFence !== -1) {
|
||
const afterFence = rawText.slice(lastFence + 3).trim()
|
||
if (afterFence.length > 3) {
|
||
return afterFence.replace(/\s+/g, ' ').trim().slice(0, 500)
|
||
}
|
||
}
|
||
|
||
// Strategy 3: strip timestamp prefix and return remaining
|
||
const noTs = noXml.replace(/^\[[^\]]{5,40}\]\s*/, '').trim()
|
||
const text = noTs.replace(/\s+/g, ' ').trim()
|
||
return text.length > 5 ? text.slice(0, 500) : null
|
||
}
|
||
|
||
/**
|
||
* Extract the last user message text from a session file — used as the agent's "last task".
|
||
*
|
||
* Priority:
|
||
* 1. The original subagent spawn task ("[Subagent Task]: ...") — scan from the start
|
||
* 2. Most recent real user message — scan from the end, skip system notifications
|
||
*/
|
||
async function extractLastUserTask(sessionFilePath: string): Promise<string | null> {
|
||
try {
|
||
const content = await fs.readFile(sessionFilePath, 'utf8')
|
||
const allLines = content.split('\n').filter(l => l.trim())
|
||
|
||
// Pass 1: find the first [Subagent Task] (set at spawn time, stable throughout session)
|
||
for (const line of allLines.slice(0, 40)) {
|
||
try {
|
||
const record = JSON.parse(line)
|
||
if (record.type !== 'message' || !record.message) continue
|
||
if (record.message.role !== 'user') continue
|
||
const blocks = Array.isArray(record.message.content) ? record.message.content : []
|
||
for (const block of blocks) {
|
||
if (block?.type !== 'text' || typeof block.text !== 'string') continue
|
||
if (!block.text.includes('[Subagent Task]')) continue
|
||
const text = extractUserText(block.text)
|
||
if (text) return text
|
||
}
|
||
} catch { /* skip */ }
|
||
}
|
||
|
||
// Pass 2: most recent real user message (direct agents, e.g. main)
|
||
const skipPhrases = ['A completed subagent task', 'Action:\n', 'END_UNTRUSTED_CHILD_RESULT', 'Continue where you left off']
|
||
const recent = allLines.slice(-80)
|
||
for (let i = recent.length - 1; i >= 0; i--) {
|
||
try {
|
||
const record = JSON.parse(recent[i])
|
||
if (record.type !== 'message' || !record.message) continue
|
||
if (record.message.role !== 'user') continue
|
||
const blocks = Array.isArray(record.message.content) ? record.message.content : []
|
||
for (const block of blocks) {
|
||
if (block?.type !== 'text' || typeof block.text !== 'string') continue
|
||
if (skipPhrases.some(p => block.text.includes(p))) continue
|
||
const text = extractUserText(block.text)
|
||
if (text) return text
|
||
}
|
||
} catch { /* skip */ }
|
||
}
|
||
} catch { /* ignore */ }
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Read last N lines of a JSONL session file and determine the agent's true working state.
|
||
*
|
||
* Logic:
|
||
* - Last message role is 'toolResult' → working (parent about to process tool output)
|
||
* - Last assistant stopReason is 'toolUse' → working (tool call in flight)
|
||
* - Last assistant stopReason is 'stop' → idle (turn completed, waiting for next input)
|
||
* - Fallback: time-based heuristic
|
||
*/
|
||
async function detectStateFromSession(
|
||
sessionFilePath: string,
|
||
now: number,
|
||
lastActive: number,
|
||
): Promise<'idle' | 'working' | 'offline'> {
|
||
if (lastActive === 0) return 'offline'
|
||
|
||
const OFFLINE_MS = 10 * 60 * 1000 // idle > 10 min → offline
|
||
const WORKING_MAX_MS = 10 * 60 * 1000 // working > 10 min → force idle
|
||
const timeDiff = now - lastActive
|
||
|
||
// Last activity > 10 min ago → offline regardless of session content
|
||
if (timeDiff > OFFLINE_MS) return 'offline'
|
||
|
||
try {
|
||
const content = await fs.readFile(sessionFilePath, 'utf8')
|
||
const lines = content.split('\n').filter(l => l.trim()).slice(-50)
|
||
|
||
let lastRole: string | null = null
|
||
let lastStopReason: string | null = null
|
||
|
||
for (let i = lines.length - 1; i >= 0; i--) {
|
||
try {
|
||
const record = JSON.parse(lines[i])
|
||
if (record.type !== 'message' || !record.message) continue
|
||
const { role, stopReason } = record.message
|
||
if (!lastRole) lastRole = role ?? null
|
||
if (role === 'assistant') {
|
||
lastStopReason = stopReason ?? null
|
||
break
|
||
}
|
||
} catch { /* skip malformed line */ }
|
||
}
|
||
|
||
// Work completed → idle ONLY if the last message itself was the assistant finishing
|
||
if (lastRole === 'assistant' && lastStopReason === 'stop') return 'idle'
|
||
// New user/tool message after assistant stop, or tool call in flight → working
|
||
if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') {
|
||
return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle'
|
||
}
|
||
// assistant with no stopReason = mid-generation (streaming), treat as working
|
||
if (lastRole === 'assistant' && !lastStopReason) {
|
||
return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle'
|
||
}
|
||
} catch { /* file unreadable — fall through */ }
|
||
|
||
// Fallback within the 10-min window
|
||
return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle'
|
||
}
|
||
|
||
export async function GET() {
|
||
const configPath = OPENCLAW_CONFIG_PATH
|
||
const agentsDir = OPENCLAW_AGENTS_DIR
|
||
|
||
const agents: AgentActivity[] = []
|
||
|
||
try {
|
||
if (existsSync(configPath)) {
|
||
const configContent = await fs.readFile(configPath, 'utf8')
|
||
const config = parseJsonText(configContent)
|
||
|
||
const agentList = await loadAgentList(config, agentsDir)
|
||
if (agentList.length > 0) {
|
||
const now = Date.now()
|
||
const liveCronJobs = await loadCronJobs(config)
|
||
|
||
for (const agent of agentList) {
|
||
let lastActive = 0
|
||
let mostRecentSessionFile: string | null = null
|
||
let agentSessionsDir = ''
|
||
|
||
// Resolve emoji: IDENTITY.md > agent.json > openclaw.json > default
|
||
let agentJsonEmoji: string | undefined
|
||
if (existsSync(agentsDir)) {
|
||
// 1. Read from workspace IDENTITY.md ("- **Emoji:** 🌸")
|
||
const workspaceDir = typeof (agent as any).workspace === 'string' ? (agent as any).workspace : null
|
||
if (workspaceDir) {
|
||
const identityPath = path.join(workspaceDir, 'IDENTITY.md')
|
||
if (existsSync(identityPath)) {
|
||
try {
|
||
const identityRaw = await fs.readFile(identityPath, 'utf8')
|
||
const emojiLine = identityRaw.split(/\r?\n/).find((line) => /\*\*Emoji:\*\*/.test(line))
|
||
const m = emojiLine?.match(/\*\*Emoji:\*\*\s*(.*)$/)
|
||
if (m) {
|
||
const emoji = normalizeIdentityEmoji(m[1])
|
||
if (emoji) agentJsonEmoji = emoji
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
}
|
||
// 2. Fallback: read from agent's agent.json emoji field
|
||
if (!agentJsonEmoji) {
|
||
const agentJsonPath = path.join(agentsDir, agent.id, 'agent', 'agent.json')
|
||
if (existsSync(agentJsonPath)) {
|
||
try {
|
||
const raw = await fs.readFile(agentJsonPath, 'utf8')
|
||
const parsed = JSON.parse(raw)
|
||
const emoji = normalizeIdentityEmoji(parsed?.emoji)
|
||
if (emoji) {
|
||
agentJsonEmoji = emoji
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (existsSync(agentsDir)) {
|
||
agentSessionsDir = path.join(agentsDir, agent.id, 'sessions')
|
||
if (existsSync(agentSessionsDir)) {
|
||
// Use JSONL file mtime for lastActive (reliable, updates on every message write).
|
||
// Also build a map from sessionId → filePath using sessions.json, so we can
|
||
// pick the correct file for content-based state detection.
|
||
const sessionIdToFile = new Map<string, string>()
|
||
try {
|
||
const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json')
|
||
if (existsSync(sessionsIndexPath)) {
|
||
const raw = await fs.readFile(sessionsIndexPath, 'utf8')
|
||
const index = JSON.parse(raw) as Record<string, { sessionId?: string }>
|
||
for (const [, meta] of Object.entries(index)) {
|
||
if (typeof meta.sessionId === 'string') {
|
||
sessionIdToFile.set(meta.sessionId, path.join(agentSessionsDir, `${meta.sessionId}.jsonl`))
|
||
}
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
try {
|
||
const files = await fs.readdir(agentSessionsDir)
|
||
for (const file of files) {
|
||
if (!file.endsWith('.jsonl')) continue
|
||
const filePath = path.join(agentSessionsDir, file)
|
||
const stat = await fs.stat(filePath)
|
||
if (stat.mtimeMs > lastActive) {
|
||
lastActive = stat.mtimeMs
|
||
mostRecentSessionFile = filePath
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
// If the most-recent .jsonl is not in sessions.json, keep it when it's
|
||
// recent (< 5 min) — it may be a brand-new session not yet indexed.
|
||
// Only fall back to the sessions.json-indexed file when the un-indexed
|
||
// file is stale, to avoid reading probe/temp files from old runs.
|
||
if (mostRecentSessionFile) {
|
||
const sessionId = path.basename(mostRecentSessionFile, '.jsonl')
|
||
if (!sessionIdToFile.has(sessionId)) {
|
||
const unindexedAge = now - lastActive
|
||
if (unindexedAge > 5 * 60 * 1000) {
|
||
// Stale un-indexed file — prefer the best sessions.json entry
|
||
let bestMtime = 0
|
||
for (const [, fp] of sessionIdToFile) {
|
||
try {
|
||
const s = await fs.stat(fp)
|
||
if (s.mtimeMs > bestMtime) {
|
||
bestMtime = s.mtimeMs
|
||
mostRecentSessionFile = fp
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
if (bestMtime > 0) lastActive = bestMtime
|
||
}
|
||
// else: keep the un-indexed file — it's a fresh active session
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Determine state from session content (falls back to time-based)
|
||
let state: 'idle' | 'working' | 'waiting' | 'offline'
|
||
if (mostRecentSessionFile && existsSync(mostRecentSessionFile)) {
|
||
state = await detectStateFromSession(mostRecentSessionFile, now, lastActive)
|
||
} else {
|
||
const timeDiff = now - lastActive
|
||
if (lastActive === 0 || timeDiff > 10 * 60 * 1000) state = 'offline'
|
||
else if (timeDiff <= 2 * 60 * 1000) state = 'working'
|
||
else state = 'idle'
|
||
}
|
||
|
||
// Parse subagents for online agents
|
||
let subagents: SubagentInfo[] | undefined
|
||
let cronJobs: CronJobInfo[] | undefined
|
||
if (state !== 'offline' && agentSessionsDir && existsSync(agentSessionsDir)) {
|
||
subagents = await parseSubagents(agentSessionsDir, agent.id)
|
||
if (subagents.length === 0) subagents = undefined
|
||
cronJobs = await parseCronJobs(
|
||
agentSessionsDir,
|
||
liveCronJobs.filter((job) => inferCronOwnerAgentId(job) === agent.id),
|
||
)
|
||
if (cronJobs.length === 0) cronJobs = undefined
|
||
}
|
||
|
||
// Extract last user task for working agents
|
||
let lastTask: string | undefined
|
||
if (state === 'working' && mostRecentSessionFile && existsSync(mostRecentSessionFile)) {
|
||
lastTask = (await extractLastUserTask(mostRecentSessionFile)) ?? undefined
|
||
}
|
||
|
||
agents.push({
|
||
agentId: agent.id,
|
||
name: agent.name || agent.id,
|
||
emoji: agentJsonEmoji || normalizeIdentityEmoji(agent.identity?.emoji) || normalizeIdentityEmoji(agent.emoji) || '🤖',
|
||
state,
|
||
lastActive,
|
||
subagents,
|
||
cronJobs,
|
||
lastTask,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error reading agent activity:', error)
|
||
}
|
||
|
||
return NextResponse.json(
|
||
{ agents },
|
||
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0' } },
|
||
)
|
||
}
|