feat(monitor): infer spawnedBy for subagent sessions (#13)

Client-side workaround for linking subagent sessions to parent sessions
without server support. Tracks recent parent session activity and infers
spawn relationships based on timing (5-second window).

- Add spawnedBy field to SessionInfo and MonitorSession types
- Track parent session activity timestamps
- Infer parent when subagent session first appears
- Preserve spawnedBy once set (immutable)
- Handle inference during hydration
This commit is contained in:
Luciano Castillo
2026-01-28 11:41:13 -05:00
committed by GitHub
parent c6990f5d86
commit e690c847fc
3 changed files with 122 additions and 9 deletions
+117 -9
View File
@@ -1,9 +1,51 @@
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
import type { MonitorSession, MonitorAction } from './protocol'
import { parseSessionKey, type MonitorSession, type MonitorAction } from './protocol'
// Track runId → sessionKey mapping (learned from chat events)
const runSessionMap = new Map<string, string>()
// Track recent activity on parent (non-subagent) sessions for spawn inference
// Maps sessionKey → lastActivityTimestamp
const parentSessionActivity = new Map<string, number>()
// Time window for spawn inference - parent must have been active within this window
const SPAWN_INFERENCE_WINDOW_MS = 5000
function isSubagentSession(key: string): boolean {
return key.includes('subagent')
}
function isParentSession(key: string): boolean {
return !isSubagentSession(key) && !key.includes('lifecycle')
}
// Infer which parent session spawned this subagent based on recent activity
function inferSpawnedBy(subagentKey: string, timestamp?: number): string | undefined {
if (!isSubagentSession(subagentKey)) return undefined
const now = timestamp ?? Date.now()
let bestParent: string | undefined
let bestTime = 0
for (const [parentKey, activityTime] of parentSessionActivity) {
// Must be within inference window
if (now - activityTime > SPAWN_INFERENCE_WINDOW_MS) continue
// Pick most recently active parent
if (activityTime > bestTime) {
bestTime = activityTime
bestParent = parentKey
}
}
return bestParent
}
// Track activity on a parent session
function trackParentActivity(sessionKey: string, timestamp?: number) {
if (!isParentSession(sessionKey)) return
parentSessionActivity.set(sessionKey, timestamp ?? Date.now())
}
export const sessionsCollection = createCollection(
localOnlyCollectionOptions<MonitorSession>({
id: 'clawdbot-sessions',
@@ -20,13 +62,32 @@ export const actionsCollection = createCollection(
// Helper to update or insert session
export function upsertSession(session: MonitorSession) {
// Track activity on parent sessions
if (isParentSession(session.key)) {
trackParentActivity(session.key, session.lastActivityAt)
}
const existing = sessionsCollection.state.get(session.key)
if (existing) {
// Preserve existing spawnedBy - never overwrite once set
const preservedSpawnedBy = existing.spawnedBy
sessionsCollection.update(session.key, (draft) => {
Object.assign(draft, session)
if (preservedSpawnedBy) {
draft.spawnedBy = preservedSpawnedBy
}
})
} else {
sessionsCollection.insert(session)
// New session - infer spawnedBy for subagents if not provided
let spawnedBy = session.spawnedBy
if (!spawnedBy && isSubagentSession(session.key)) {
spawnedBy = inferSpawnedBy(session.key, session.lastActivityAt)
}
sessionsCollection.insert({
...session,
spawnedBy,
})
}
}
@@ -40,6 +101,11 @@ export function addAction(action: MonitorAction) {
// Learn runId → sessionKey mapping from actions with real session keys
if (action.sessionKey && !action.sessionKey.includes('lifecycle')) {
runSessionMap.set(action.runId, action.sessionKey)
// Track activity on parent sessions for spawn inference
if (isParentSession(action.sessionKey)) {
trackParentActivity(action.sessionKey, action.timestamp)
}
}
// Resolve sessionKey: use mapped value if action has lifecycle/invalid key
@@ -130,11 +196,32 @@ export function updateSessionStatus(
key: string,
status: MonitorSession['status']
) {
const now = Date.now()
// Track activity on parent sessions
if (isParentSession(key)) {
trackParentActivity(key, now)
}
const session = sessionsCollection.state.get(key)
if (session) {
sessionsCollection.update(key, (draft) => {
draft.status = status
draft.lastActivityAt = Date.now()
draft.lastActivityAt = now
})
} else if (isSubagentSession(key)) {
// New subagent session via status update - create with inferred parent
const spawnedBy = inferSpawnedBy(key, now)
const parsed = parseSessionKey(key)
sessionsCollection.insert({
key,
agentId: parsed.agentId,
platform: parsed.platform,
recipient: parsed.recipient,
isGroup: parsed.isGroup,
lastActivityAt: now,
status,
spawnedBy,
})
}
}
@@ -151,6 +238,8 @@ export function updateSession(key: string, update: Partial<MonitorSession>) {
// Clear all data
export function clearCollections() {
runSessionMap.clear()
parentSessionActivity.clear()
for (const session of sessionsCollection.state.values()) {
sessionsCollection.delete(session.key)
}
@@ -167,14 +256,33 @@ export function hydrateFromServer(
// First clear existing data
clearCollections()
// Insert all sessions
for (const session of sessions) {
sessionsCollection.insert(session)
// Replay actions first to build parent activity history
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
for (const action of sortedActions) {
// Track parent activity without inserting actions yet
if (action.sessionKey && isParentSession(action.sessionKey)) {
trackParentActivity(action.sessionKey, action.timestamp)
}
}
// Replay actions through addAction to apply aggregation logic
// Sort by timestamp to ensure correct order
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
// Also track parent sessions by their lastActivityAt
for (const session of sessions) {
if (isParentSession(session.key)) {
trackParentActivity(session.key, session.lastActivityAt)
}
}
// Now insert all sessions - subagents will get inferred spawnedBy
for (const session of sessions) {
if (isSubagentSession(session.key)) {
const spawnedBy = session.spawnedBy || inferSpawnedBy(session.key, session.lastActivityAt)
sessionsCollection.insert({ ...session, spawnedBy })
} else {
sessionsCollection.insert(session)
}
}
// Replay actions through addAction for aggregation
for (const action of sortedActions) {
addAction(action)
}
+1
View File
@@ -18,6 +18,7 @@ export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
isGroup: parsed.isGroup,
lastActivityAt: info.lastActivityAt,
status: 'idle',
spawnedBy: info.spawnedBy,
}
}
+4
View File
@@ -100,6 +100,8 @@ export interface SessionInfo {
lastActivityAt: number
messageCount: number
lastMessage?: unknown
/** Session key of the parent session that spawned this subagent session. */
spawnedBy?: string
}
// App-level types
@@ -111,6 +113,8 @@ export interface MonitorSession {
isGroup: boolean
lastActivityAt: number
status: 'idle' | 'active' | 'thinking'
/** Session key of the parent session that spawned this subagent session. */
spawnedBy?: string
}
export interface MonitorAction {