feat(protocol): deliver 'War Room' UI with Incursion Engine
We've transformed the Social-Dense Hub into a high-fidelity operational surface. - BACKEND: Implemented Global Incursion Engine in agent-sessions.ts (N^2 overlap detection) and added the 60m 'Idle' state. - API: Enriched the sessions payload with full metadata and active conflict arrays. - HEADER: Delivered 4-state agent stations (Active/Stale/Evicted/Idle) with real-time 'time-ago' timers. - FEED: Implemented the 'Fire Map' visuals: * Global Incursion Ticker: High-visibility alerts for agent collisions. * Local Conflict Badges: Pulsing pills on affected task cards. - Refactored components for React-static compliance and strict TypeScript safety. This commit completes the visibility track, allowing the human supervisor to monitor agent presence and friction in real-time. OPERATIVE: silver-castle SESSION: 2026-02-14-1430
This commit is contained in:
parent
e010e0b10b
commit
eec1d6e28f
10 changed files with 224 additions and 41 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,8 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { readIssuesFromDisk } from '../../../lib/read-issues';
|
||||
import { activityEventBus } from '../../../lib/realtime';
|
||||
import { buildSessionTaskFeed, getCommunicationSummary, getAgentLivenessMap } from '../../../lib/agent-sessions';
|
||||
import { buildSessionTaskFeed, getCommunicationSummary, getAgentLivenessMap, calculateIncursions } from '../../../lib/agent-sessions';
|
||||
import { listAgents } from '../../../lib/agent-registry';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
|
|
@ -14,10 +15,18 @@ export async function GET(request: Request): Promise<Response> {
|
|||
const activity = activityEventBus.getHistory(projectRoot);
|
||||
const communication = await getCommunicationSummary();
|
||||
const livenessMap = await getAgentLivenessMap();
|
||||
const incursions = await calculateIncursions();
|
||||
const agentsResult = await listAgents({});
|
||||
|
||||
const feed = buildSessionTaskFeed(issues, activity, communication, livenessMap);
|
||||
|
||||
return NextResponse.json({ ok: true, feed });
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
feed,
|
||||
livenessMap,
|
||||
incursions,
|
||||
agents: agentsResult.data ?? []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API/Sessions] Failed to load session feed:', error);
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import type { SessionTaskCard } from '../../lib/agent-sessions';
|
||||
import type { SessionTaskCard, Incursion } from '../../lib/agent-sessions';
|
||||
import { statusBorder, statusDotColor, statusGradient, sessionStateGlow } from '../shared/status-utils';
|
||||
|
||||
interface SessionFeedCardProps {
|
||||
card: SessionTaskCard;
|
||||
onSelect: (id: string) => void;
|
||||
isHighlighted?: boolean;
|
||||
incursion?: Incursion;
|
||||
}
|
||||
|
||||
export function SessionFeedCard({ card, onSelect, isHighlighted }: SessionFeedCardProps) {
|
||||
export function SessionFeedCard({ card, onSelect, isHighlighted, incursion }: SessionFeedCardProps) {
|
||||
return (
|
||||
<motion.article
|
||||
layout
|
||||
|
|
@ -19,8 +20,20 @@ export function SessionFeedCard({ card, onSelect, isHighlighted }: SessionFeedCa
|
|||
isHighlighted
|
||||
? 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
|
||||
: `${statusBorder(card.status)} ${statusGradient(card.status)} hover:bg-white/[0.04]`
|
||||
} ${sessionStateGlow(card.sessionState)}`}
|
||||
} ${sessionStateGlow(card.sessionState)} ${incursion ? 'ring-1 ring-rose-500/30' : ''}`}
|
||||
>
|
||||
{incursion && (
|
||||
<div className="absolute -top-2 right-4 z-10">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.55rem] font-black uppercase tracking-[0.1em] border shadow-lg animate-pulse ${
|
||||
incursion.severity === 'exact'
|
||||
? 'bg-rose-500 text-white border-rose-400 shadow-rose-500/20'
|
||||
: 'bg-amber-500 text-black border-amber-400 shadow-amber-500/20'
|
||||
}`}>
|
||||
Conflict
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-[0.75rem]">
|
||||
{/* Compact Avatar */}
|
||||
<div className="flex-none">
|
||||
|
|
|
|||
|
|
@ -1,17 +1,41 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { EpicBucket } from '../../lib/agent-sessions';
|
||||
import type { EpicBucket, Incursion } from '../../lib/agent-sessions';
|
||||
import { SessionFeedCard } from './session-feed-card';
|
||||
|
||||
interface SessionTaskFeedProps {
|
||||
feed: EpicBucket[];
|
||||
incursions?: Incursion[];
|
||||
selectedEpicId: string | null;
|
||||
onSelectTask: (id: string) => void;
|
||||
highlightTaskId?: string | null;
|
||||
}
|
||||
|
||||
export function SessionTaskFeed({ feed, selectedEpicId, onSelectTask, highlightTaskId }: SessionTaskFeedProps) {
|
||||
export function IncursionTicker({ incursions }: { incursions: Incursion[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{incursions.map((inc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-3 px-4 py-2 rounded-xl border border-rose-500/20 bg-rose-500/5 backdrop-blur-md animate-in slide-in-from-top-4 duration-500`}
|
||||
>
|
||||
<div className="flex-none">
|
||||
<span className={`flex h-2 w-2 rounded-full ${inc.severity === 'exact' ? 'bg-rose-500 animate-pulse' : 'bg-amber-500'}`} />
|
||||
</div>
|
||||
<p className="ui-text text-[0.7rem] font-bold text-rose-200/80">
|
||||
<span className="uppercase tracking-widest mr-2 opacity-50">Conflict Detected:</span>
|
||||
<span className="text-white mr-2">{inc.agents.join(' & ')}</span>
|
||||
<span className="opacity-40 font-medium">overlapping in</span>
|
||||
<span className="ml-2 font-mono text-rose-300/90">{inc.scope}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionTaskFeed({ feed, incursions = [], selectedEpicId, onSelectTask, highlightTaskId }: SessionTaskFeedProps) {
|
||||
const filteredFeed = useMemo(() => {
|
||||
if (!selectedEpicId) return feed;
|
||||
return feed.filter(b => b.epic.id === selectedEpicId);
|
||||
|
|
@ -30,6 +54,12 @@ export function SessionTaskFeed({ feed, selectedEpicId, onSelectTask, highlightT
|
|||
|
||||
return (
|
||||
<div className="space-y-16 pb-24">
|
||||
{incursions.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<IncursionTicker incursions={incursions} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredFeed.map(bucket => (
|
||||
<section key={bucket.epic.id} className="space-y-[1.5rem]">
|
||||
<header className="flex items-center gap-[1rem] px-[0.5rem] group">
|
||||
|
|
@ -53,17 +83,24 @@ export function SessionTaskFeed({ feed, selectedEpicId, onSelectTask, highlightT
|
|||
</header>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-[1.5rem]">
|
||||
{bucket.tasks.map(task => (
|
||||
<SessionFeedCard
|
||||
key={task.id}
|
||||
card={task}
|
||||
onSelect={onSelectTask}
|
||||
isHighlighted={highlightTaskId === task.id}
|
||||
/>
|
||||
))}
|
||||
{bucket.tasks.map(task => {
|
||||
const taskIncursion = incursions.find(inc =>
|
||||
task.owner && inc.agents.includes(task.owner)
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionFeedCard
|
||||
key={task.id}
|
||||
card={task}
|
||||
onSelect={onSelectTask}
|
||||
isHighlighted={highlightTaskId === task.id}
|
||||
incursion={taskIncursion}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import type { AgentRecord } from '../../lib/agent-registry';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry';
|
||||
import { ProjectScopeControls } from '../shared/project-scope-controls';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ interface SessionsHeaderProps {
|
|||
needsInput: number;
|
||||
completed: number;
|
||||
};
|
||||
livenessMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SessionsHeader({
|
||||
|
|
@ -26,6 +28,7 @@ export function SessionsHeader({
|
|||
projectScopeMode,
|
||||
projectScopeOptions,
|
||||
stats,
|
||||
livenessMap = {},
|
||||
}: SessionsHeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex flex-col border-b border-white/5 bg-[#0b0c10]/60 backdrop-blur-3xl shadow-2xl">
|
||||
|
|
@ -43,6 +46,7 @@ export function SessionsHeader({
|
|||
agent={agent}
|
||||
isSelected={activeAgentId === agent.agent_id}
|
||||
onSelect={onSelectAgent}
|
||||
liveness={(livenessMap[agent.agent_id] as AgentLiveness) || 'active'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -74,25 +78,68 @@ export function SessionsHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function useTimeAgo(isoTimestamp: string) {
|
||||
const [timeAgo, setTimeAgo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const seconds = Math.floor((new Date().getTime() - new Date(isoTimestamp).getTime()) / 1000);
|
||||
if (seconds < 60) setTimeAgo(`${seconds}s`);
|
||||
else if (seconds < 3600) setTimeAgo(`${Math.floor(seconds / 60)}m`);
|
||||
else setTimeAgo(`${Math.floor(seconds / 3600)}h`);
|
||||
};
|
||||
update();
|
||||
const interval = setInterval(update, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isoTimestamp]);
|
||||
|
||||
return timeAgo;
|
||||
}
|
||||
|
||||
function AgentStation({
|
||||
agent,
|
||||
isSelected,
|
||||
onSelect
|
||||
onSelect,
|
||||
liveness
|
||||
}: {
|
||||
agent: AgentRecord,
|
||||
isSelected: boolean,
|
||||
onSelect: (id: string | null) => void
|
||||
onSelect: (id: string | null) => void,
|
||||
liveness: AgentLiveness
|
||||
}) {
|
||||
const isActive = agent.status !== 'idle';
|
||||
const timeAgo = useTimeAgo(agent.last_seen_at);
|
||||
|
||||
const statusStyles = {
|
||||
active: {
|
||||
dot: 'bg-emerald-500 animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.8)]',
|
||||
label: 'On Mission',
|
||||
color: 'text-emerald-400/60'
|
||||
},
|
||||
stale: {
|
||||
dot: 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]',
|
||||
label: 'Lease Expiring',
|
||||
color: 'text-amber-400/60'
|
||||
},
|
||||
evicted: {
|
||||
dot: 'bg-rose-500/50 shadow-none',
|
||||
label: 'Disconnected',
|
||||
color: 'text-rose-400/40'
|
||||
},
|
||||
idle: {
|
||||
dot: 'bg-zinc-700 shadow-none',
|
||||
label: 'Idle',
|
||||
color: 'text-zinc-500/30'
|
||||
}
|
||||
}[liveness];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(isSelected ? null : agent.agent_id)}
|
||||
className={`flex-none group flex w-[9.5rem] items-center gap-2 rounded-lg border px-2 py-1.5 transition-all duration-300 ${
|
||||
className={`flex-none group flex w-[10rem] items-center gap-2 rounded-lg border px-2 py-1.5 transition-all duration-300 ${
|
||||
isSelected
|
||||
? 'border-sky-500/50 bg-sky-500/10 shadow-[0_0_10px_rgba(14,165,233,0.1)]'
|
||||
: 'border-white/5 bg-white/[0.01] hover:bg-white/5'
|
||||
}`}
|
||||
} ${liveness === 'idle' ? 'opacity-40 grayscale-[0.5]' : ''}`}
|
||||
>
|
||||
<div className="relative flex-none">
|
||||
<div className={`h-7 w-7 rounded-md bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border border-white/10 shadow-inner transition-transform duration-300 ${isSelected ? 'scale-90' : 'group-hover:scale-105'}`}>
|
||||
|
|
@ -100,17 +147,20 @@ function AgentStation({
|
|||
{agent.agent_id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border-2 border-[#0b0c10] ${
|
||||
isActive ? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.8)]' : 'bg-zinc-600'
|
||||
}`} />
|
||||
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border-2 border-[#0b0c10] ${statusStyles.dot}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start min-w-0">
|
||||
<span className={`ui-text text-[0.65rem] font-black truncate w-full transition-colors ${isSelected ? 'text-sky-300' : 'text-text-body'}`}>
|
||||
{agent.agent_id}
|
||||
</span>
|
||||
<span className="system-data text-[0.5rem] font-bold text-text-muted/30 uppercase tracking-tighter">
|
||||
{isActive ? 'On Mission' : 'Standby'}
|
||||
<div className="flex flex-col items-start min-w-0 text-left">
|
||||
<div className="flex items-center gap-1 w-full justify-between pr-1">
|
||||
<span className={`ui-text text-[0.65rem] font-black truncate transition-colors ${isSelected ? 'text-sky-300' : 'text-text-body'}`}>
|
||||
{agent.agent_id}
|
||||
</span>
|
||||
<span className="system-data text-[0.5rem] font-bold text-text-muted/40">
|
||||
{timeAgo}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`system-data text-[0.5rem] font-bold uppercase tracking-tighter ${statusStyles.color}`}>
|
||||
{statusStyles.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function SessionsPage({
|
|||
projectScopeMode,
|
||||
}: SessionsPageProps) {
|
||||
// 2. Session-specific feed
|
||||
const { feed, loading, refresh: refreshFeed, stats } = useSessionFeed(projectRoot);
|
||||
const { feed, incursions, livenessMap, loading, refresh: refreshFeed, stats } = useSessionFeed(projectRoot);
|
||||
|
||||
const {
|
||||
selectedAgentId,
|
||||
|
|
@ -73,6 +73,7 @@ export function SessionsPage({
|
|||
projectScopeMode={projectScopeMode}
|
||||
projectScopeOptions={projectScopeOptions}
|
||||
stats={stats}
|
||||
livenessMap={livenessMap}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -97,6 +98,7 @@ export function SessionsPage({
|
|||
) : (
|
||||
<SessionTaskFeed
|
||||
feed={feed}
|
||||
incursions={incursions}
|
||||
selectedEpicId={selectedEpicId}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
highlightTaskId={selectedTaskId}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import type { EpicBucket } from '../lib/agent-sessions';
|
|||
|
||||
export function useSessionFeed(projectRoot: string) {
|
||||
const [feed, setFeed] = useState<EpicBucket[]>([]);
|
||||
const [livenessMap, setLivenessMap] = useState<Record<string, string>>({});
|
||||
const [incursions, setIncursions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -18,6 +20,12 @@ export function useSessionFeed(projectRoot: string) {
|
|||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setFeed(data.feed);
|
||||
if (data.livenessMap) {
|
||||
setLivenessMap(data.livenessMap);
|
||||
}
|
||||
if (data.incursions) {
|
||||
setIncursions(data.incursions);
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error?.message || 'Failed to fetch session feed');
|
||||
}
|
||||
|
|
@ -34,6 +42,8 @@ export function useSessionFeed(projectRoot: string) {
|
|||
|
||||
return {
|
||||
feed,
|
||||
livenessMap,
|
||||
incursions,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchFeed,
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ export interface ActivityLeaseInput {
|
|||
agent: string;
|
||||
}
|
||||
|
||||
export type AgentLiveness = 'active' | 'stale' | 'evicted';
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
|
@ -260,16 +258,23 @@ export async function showAgent(input: ShowAgentInput): Promise<AgentCommandResp
|
|||
}
|
||||
}
|
||||
|
||||
export type AgentLiveness = 'active' | 'stale' | 'evicted' | 'idle';
|
||||
|
||||
/**
|
||||
* Derives the liveness state of an agent based on its last seen timestamp.
|
||||
* stale threshold: staleMinutes (default 15)
|
||||
* evicted threshold: 2 * staleMinutes (default 30)
|
||||
* active: < 15m
|
||||
* stale: 15m - 30m
|
||||
* evicted: 30m - 60m
|
||||
* idle: >= 60m
|
||||
*/
|
||||
export function deriveLiveness(lastSeenAt: string, now: Date = new Date(), staleMinutes: number = 15): AgentLiveness {
|
||||
const lastSeen = new Date(lastSeenAt).getTime();
|
||||
const diffMs = now.getTime() - lastSeen;
|
||||
const diffMin = diffMs / (1000 * 60);
|
||||
|
||||
if (diffMin >= 60) {
|
||||
return 'idle';
|
||||
}
|
||||
if (diffMin >= 2 * staleMinutes) {
|
||||
return 'evicted';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import type { ActivityEvent } from './activity';
|
|||
import type { BeadIssue } from './types';
|
||||
import { listAgents, deriveLiveness } from './agent-registry';
|
||||
import { inboxAgentMessages, type AgentMessage } from './agent-mail';
|
||||
import { statusAgentReservations, classifyOverlap } from './agent-reservations';
|
||||
|
||||
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted';
|
||||
export type AgentSessionState = 'active' | 'reviewing' | 'deciding' | 'needs_input' | 'completed' | 'stale' | 'evicted' | 'idle';
|
||||
|
||||
export interface SessionTaskCard {
|
||||
id: string;
|
||||
|
|
@ -50,6 +51,49 @@ export async function getAgentLivenessMap(): Promise<Record<string, string>> {
|
|||
return map;
|
||||
}
|
||||
|
||||
export interface Incursion {
|
||||
scope: string;
|
||||
agents: string[];
|
||||
severity: 'exact' | 'partial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates global incursions by comparing all active reservations.
|
||||
*/
|
||||
export async function calculateIncursions(): Promise<Incursion[]> {
|
||||
const statusResult = await statusAgentReservations({});
|
||||
if (!statusResult.ok || !statusResult.data) return [];
|
||||
|
||||
const reservations = statusResult.data.reservations;
|
||||
const incursions: Incursion[] = [];
|
||||
const processedPairs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < reservations.length; i++) {
|
||||
for (let j = i + 1; j < reservations.length; j++) {
|
||||
const resA = reservations[i];
|
||||
const resB = reservations[j];
|
||||
|
||||
// Don't compare an agent against themselves
|
||||
if (resA.agent_id === resB.agent_id) continue;
|
||||
|
||||
const overlap = classifyOverlap(resA.scope, resB.scope);
|
||||
if (overlap !== 'disjoint') {
|
||||
const key = [resA.agent_id, resB.agent_id].sort().join(':') + ':' + [resA.scope, resB.scope].sort().join('|');
|
||||
if (processedPairs.has(key)) continue;
|
||||
processedPairs.add(key);
|
||||
|
||||
incursions.push({
|
||||
scope: overlap === 'exact' ? resA.scope : `${resA.scope} ↔ ${resB.scope}`,
|
||||
agents: [resA.agent_id, resB.agent_id],
|
||||
severity: overlap
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return incursions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers all relevant communication for all agents to build a summary for aggregation.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -82,9 +82,22 @@ test('deriveLiveness follows threshold rules (15m/30m default)', () => {
|
|||
);
|
||||
|
||||
// Evicted: 1 hour ago
|
||||
// Note: Since we added Idle at 60m, let's test 59m for Evicted and 60m for Idle
|
||||
assert.equal(
|
||||
deriveLiveness('2026-02-14T11:01:00Z', now),
|
||||
'evicted'
|
||||
);
|
||||
|
||||
// Idle: Exactly 60 mins ago
|
||||
assert.equal(
|
||||
deriveLiveness('2026-02-14T11:00:00Z', now),
|
||||
'evicted'
|
||||
'idle'
|
||||
);
|
||||
|
||||
// Idle: 2 hours ago
|
||||
assert.equal(
|
||||
deriveLiveness('2026-02-14T10:00:00Z', now),
|
||||
'idle'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue