Add fleet phenotype cloning for sibling machines.

Publish winning tier paths per host fingerprint on hashrate success, inherit on auth before adaptive strategy, and surface clone badges in LOTL Timeline and Access Depth.
This commit is contained in:
AetherForge
2026-06-07 02:28:00 -07:00
parent bb48515b2b
commit 4204dd6b6d
25 changed files with 1421 additions and 38 deletions

View File

@@ -69,6 +69,12 @@
font-style: italic;
}
.access-depth-phenotype {
margin-top: 0.45rem;
font-size: 0.72rem;
color: var(--text-muted);
}
.access-depth-join {
display: flex;
flex-wrap: wrap;
@@ -192,6 +198,21 @@
border: 1px solid rgba(255, 180, 60, 0.3);
}
.access-depth-atlas-list {
margin: 0;
padding-left: 1.1rem;
font-size: 0.78rem;
color: var(--text-muted);
}
.access-depth-atlas-row {
margin: 0.2rem 0;
}
.access-depth-onion-item--skipped_by_atlas .access-depth-onion-label {
opacity: 0.7;
}
.access-depth-source {
margin-left: 0.35rem;
color: var(--text-muted);
@@ -220,3 +241,35 @@
.access-depth-calibrate-link:hover {
text-decoration: underline;
}
.access-depth-clearance-badge {
font-family: var(--font-tech);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--neon-cyan);
border: 1px solid rgba(0, 245, 255, 0.45);
border-radius: 3px;
padding: 1px 6px;
cursor: help;
}
.access-depth-clearance-flash {
font-size: 0.68rem;
color: var(--neon-amber, #ffb347);
background: rgba(255, 180, 60, 0.12);
border: 1px solid rgba(255, 180, 60, 0.45);
border-radius: 3px;
padding: 2px 8px;
animation: access-depth-clearance-flash 1.2s ease-in-out 2;
}
@keyframes access-depth-clearance-flash {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}

View File

@@ -12,6 +12,10 @@ import {
parseAccessDepthServerPolicy,
} from '../../help/accessDepth';
vi.mock('../../hooks/useWebSocket', () => ({
useWebSocket: () => ({ latestMessage: null }),
}));
vi.mock('../../api/client', () => ({
api: {
getConfig: vi.fn().mockResolvedValue({
@@ -155,6 +159,22 @@ describe('AccessDepthPanel', () => {
expect(await screen.findByText('WinRM')).toBeInTheDocument();
});
it('shows phenotype source when inherited phenotype present', async () => {
renderPanel(
mockAgent({
platform: 'windows',
inherited_phenotype: {
source_agent_name: 'worker-07',
spread_lane: 'winrm',
tier_order: ['container', 'wsl', 'cpu_inprocess'],
},
}),
);
expect(await screen.findByText(/phenotype cloned from/i)).toBeInTheDocument();
expect(screen.getByText('worker-07')).toBeInTheDocument();
expect(await screen.findByText('WinRM')).toBeInTheDocument();
});
it('renders adaptive strategy reasoning fixtures', () => {
renderPanel(
mockAgent({ platform: 'windows', status: 'online' }),
@@ -180,6 +200,13 @@ describe('AccessDepthPanel', () => {
expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument();
});
it('renders clearance badge L0L4 with tooltip permissions', () => {
renderPanel(mockAgent({ clearance_level: 2 }));
const badge = screen.getByLabelText(/Clearance L2/i);
expect(badge).toHaveTextContent('L2');
expect(badge.getAttribute('title')).toMatch(/spread/i);
});
it('shows pending chain when tiers not yet attempted', () => {
renderPanel(
mockAgent({

View File

@@ -1,11 +1,18 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import {
atlasSkipDisplayLabel,
buildAccessDepthModel,
parseAccessDepthServerPolicy,
type AccessDepthDiagnostics,
} from '../../help/accessDepth';
import {
clearanceLabel,
clearancePermissions,
formatClearanceElevation,
} from '../../help/clearance';
import { useWebSocket } from '../../hooks/useWebSocket';
import type { Agent } from '../../types';
import { HelpTip } from '../HelpTip';
import JoinLaneBadge from './JoinLaneBadge';
@@ -31,6 +38,9 @@ function OnionList({ rows, empty }: { rows: ReturnType<typeof buildAccessDepthMo
<span className="access-depth-onion-label">{row.label}</span>
{row.status === 'active' && <span className="access-depth-tag access-depth-tag--active">active</span>}
{row.status === 'skipped' && <span className="access-depth-tag access-depth-tag--skip">skipped</span>}
{row.status === 'skipped_by_atlas' && (
<span className="access-depth-tag access-depth-tag--skip">atlas skip</span>
)}
{row.status === 'done' && <span className="access-depth-tag access-depth-tag--ok">ok</span>}
{row.status === 'failed' && <span className="access-depth-tag access-depth-tag--fail">fail</span>}
{row.status === 'pending' && <span className="access-depth-tag access-depth-tag--pending">pending</span>}
@@ -63,8 +73,38 @@ function AttemptMiniList({
}
export default function AccessDepthPanel({ agent, diagnostics }: Props) {
const { latestMessage } = useWebSocket();
const [policyLoaded, setPolicyLoaded] = useState(false);
const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({}));
const [elevationFlash, setElevationFlash] = useState<string | null>(null);
const flashTimerRef = useRef<number | null>(null);
const clearanceLevel = agent.clearance_level ?? 1;
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'clearance_elevated') return;
const p = latestMessage.payload as {
agent_id?: string;
to_level?: number;
source?: string;
reason?: string;
};
if (p.agent_id !== agent.id || typeof p.to_level !== 'number') return;
const text = formatClearanceElevation({
to_level: p.to_level,
source: p.source,
reason: p.reason,
});
setElevationFlash(text);
if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current);
flashTimerRef.current = window.setTimeout(() => setElevationFlash(null), 6000);
}, [latestMessage, agent.id]);
useEffect(() => {
return () => {
if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current);
};
}, []);
useEffect(() => {
let cancelled = false;
@@ -95,6 +135,18 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
<span className="lotl-attempts-title">
ACCESS DEPTH <HelpTip field="crucible_access_depth" />
</span>
<span
className="access-depth-clearance-badge"
title={clearancePermissions(clearanceLevel)}
aria-label={`Clearance ${clearanceLabel(clearanceLevel)}: ${clearancePermissions(clearanceLevel)}`}
>
{clearanceLabel(clearanceLevel)}
</span>
{elevationFlash && (
<span className="access-depth-clearance-flash" role="status">
{elevationFlash}
</span>
)}
{!policyLoaded && <span className="access-depth-muted">loading policy</span>}
</div>
@@ -137,6 +189,17 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
) : (
<div className="access-depth-muted">No join lane yet</div>
)}
{model.phenotypeSource && (
<div className="access-depth-phenotype">
phenotype cloned from <strong>{model.phenotypeSource}</strong>
{model.phenotypeSpreadLane ? (
<>
{' '}
· spread <JoinLaneBadge lane={model.phenotypeSpreadLane} />
</>
) : null}
</div>
)}
</div>
<div className="access-depth-section">
@@ -161,6 +224,19 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
</div>
</div>
{model.atlasSkips.length > 0 && (
<div className="access-depth-section access-depth-atlas-block">
<div className="access-depth-section-title">Atlas skips</div>
<ul className="access-depth-atlas-list">
{model.atlasSkips.map((skip) => (
<li key={`${skip.tier}-${skip.condition}`} className="access-depth-atlas-row">
{atlasSkipDisplayLabel(skip)}
</li>
))}
</ul>
</div>
)}
{model.strategyReasoning.length > 0 && (
<div className="access-depth-section access-depth-strategy-block">
<div className="access-depth-section-title">

View File

@@ -40,6 +40,20 @@ describe('LotlTierTimeline', () => {
expect(screen.getByText('GPO')).toBeInTheDocument();
});
it('shows cloned-from badge when inherited phenotype present', () => {
const order = resolveLotlTierOrder();
const agent = mockAgent({
inherited_phenotype: {
source_agent_name: 'worker-07',
tier_order: ['container', 'wsl'],
spread_lane: 'winrm',
},
});
const model = buildLotlTimelineModel(agent, order, []);
render(<LotlTierTimeline model={model} agentName="Node B" clonedFrom="worker-07" />);
expect(screen.getByText('Cloned from worker-07')).toBeInTheDocument();
});
it('expands attempt detail on tier click', () => {
const order = resolveLotlTierOrder();
const agent = mockAgent({

View File

@@ -7,6 +7,7 @@ import '../Fleet/LotlVisuals.css';
interface Props {
model: LotlTimelineModel;
agentName: string;
clonedFrom?: string;
className?: string;
}
@@ -17,6 +18,7 @@ function stateGlyph(state: LotlTimelineTierRow['state']): string {
case 'failed':
return '✗';
case 'skipped':
case 'skipped_by_atlas':
return '—';
case 'trying':
return '◉';
@@ -49,14 +51,18 @@ function TierDetail({ row }: { row: LotlTimelineTierRow }) {
</>
) : (
<div className="lotl-tier-detail-row">
{row.state === 'skipped' ? 'Tier skipped by policy' : 'No attempt recorded yet'}
{row.state === 'skipped_by_atlas'
? 'Tier skipped by failure atlas'
: row.state === 'skipped'
? 'Tier skipped by policy'
: 'No attempt recorded yet'}
</div>
)}
</div>
);
}
export default function LotlTierTimeline({ model, agentName, className = '' }: Props) {
export default function LotlTierTimeline({ model, agentName, clonedFrom, className = '' }: Props) {
const [expandedTier, setExpandedTier] = useState<string | null>(null);
const expandedRow = useMemo(
@@ -73,7 +79,14 @@ export default function LotlTierTimeline({ model, agentName, className = '' }: P
<div className="lotl-tier-timeline-header">
<div>
<div className="lotl-tier-timeline-title">ONION TIER CHAIN</div>
<div className="lotl-tier-timeline-agent">{agentName}</div>
<div className="lotl-tier-timeline-agent">
{agentName}
{clonedFrom && (
<span className="lotl-phenotype-badge" title={`Cloned tier order from ${clonedFrom}`}>
Cloned from {clonedFrom}
</span>
)}
</div>
</div>
<div className="lotl-tier-timeline-progress">
{model.succeeded}/{model.total} tiers succeeded

View File

@@ -116,6 +116,19 @@
color: var(--text-muted);
}
.lotl-phenotype-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
border-radius: 3px;
font-size: 0.62rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9dffb8;
background: rgba(40, 120, 80, 0.25);
border: 1px solid rgba(80, 200, 130, 0.35);
}
.lotl-tier-timeline-progress {
font-family: var(--font-tech);
font-size: 0.75rem;
@@ -185,12 +198,17 @@
border-color: rgba(255, 255, 255, 0.1);
}
.lotl-tier-step--skipped .lotl-tier-node {
.lotl-tier-step--skipped .lotl-tier-node,
.lotl-tier-step--skipped_by_atlas .lotl-tier-node {
color: rgba(180, 180, 190, 0.5);
border-color: rgba(255, 255, 255, 0.06);
opacity: 0.55;
}
.lotl-tier-step--skipped_by_atlas .lotl-tier-node {
border-color: rgba(255, 140, 80, 0.25);
}
.lotl-tier-step--success .lotl-tier-node {
color: #7dffaa;
border-color: rgba(100, 220, 140, 0.45);
@@ -302,6 +320,43 @@
margin-bottom: 0.25rem;
}
.lotl-ai-decision--court {
border-color: rgba(255, 170, 100, 0.35);
background: rgba(40, 24, 8, 0.35);
}
.lotl-court-role {
margin-top: 0.35rem;
}
.lotl-court-role-label {
display: block;
font-family: var(--font-tech);
font-size: 0.58rem;
letter-spacing: 0.08em;
color: #ffaa66;
margin-bottom: 0.15rem;
}
.lotl-court-role-text {
color: var(--text-muted);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.35;
}
.lotl-ai-decision-commands {
margin-top: 0.25rem;
font-size: 0.68rem;
color: #7dffaa;
}
.lotl-ai-decision-ts {
margin-top: 0.25rem;
font-size: 0.65rem;
color: var(--text-muted);
}
.lotl-timeline-links {
margin-top: 0.85rem;
font-size: 0.72rem;
@@ -324,6 +379,43 @@
font-size: 0.85rem;
}
.lotl-clearance-timeline {
margin-top: 0.85rem;
padding: 0.65rem 0.75rem;
border-radius: var(--deck-card-radius, 6px);
border: 1px solid rgba(255, 180, 60, 0.25);
background: rgba(8, 6, 4, 0.45);
}
.lotl-clearance-event-list {
list-style: none;
margin: 0.35rem 0 0;
padding: 0;
}
.lotl-clearance-event-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.35rem 0.75rem;
font-size: 0.68rem;
padding: 0.2rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.lotl-clearance-event-row:last-child {
border-bottom: none;
}
.lotl-clearance-event-summary {
color: var(--neon-amber, #ffb347);
}
.lotl-clearance-event-ts {
color: var(--text-muted);
font-size: 0.62rem;
}
@media (max-width: 720px) {
.lotl-tier-track {
flex-direction: column;

View File

@@ -27,10 +27,17 @@ export interface AdaptiveStrategyView {
updated_at?: string;
}
export interface AtlasSkipView {
tier: string;
condition: string;
reason: string;
}
export interface AccessDepthDiagnostics {
environment_probes?: EnvironmentProbes;
tier_chain_order?: string[];
tier_chain_skipped?: string[];
atlas_skips?: AtlasSkipView[];
lotl_tier?: string;
lotl_attempts?: TierAttempt[];
active_method?: string;
@@ -67,7 +74,8 @@ export interface OnionTierRow {
index: number;
tier: string;
label: string;
status: 'active' | 'skipped' | 'pending' | 'done' | 'failed' | 'neutral';
status: 'active' | 'skipped' | 'skipped_by_atlas' | 'pending' | 'done' | 'failed' | 'neutral';
atlasCondition?: string;
}
export interface AccessDepthModel {
@@ -92,6 +100,9 @@ export interface AccessDepthModel {
adaptiveActive: boolean;
strategyReasoning: StrategyReason[];
adaptiveConfidence?: number;
atlasSkips: AtlasSkipView[];
phenotypeSource?: string;
phenotypeSpreadLane?: string;
}
/** Default mining tier onion pushed at agent auth when Calibrate sends no override. */
@@ -143,11 +154,13 @@ export function parseAccessDepthDiagnostics(raw: Record<string, unknown>): Acces
const adaptive = parseAdaptiveStrategy(raw.adaptive_strategy);
const reasoning = parseStrategyReasoning(raw.strategy_reasoning) ?? adaptive?.reasoning;
const atlas_skips = parseAtlasSkips(raw.atlas_skips);
return {
environment_probes: parseEnvironmentProbes(raw.environment_probes),
tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined,
tier_chain_skipped: tier_chain_skipped?.length ? tier_chain_skipped : undefined,
atlas_skips,
lotl_tier,
lotl_attempts: attempts.length ? attempts : undefined,
active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined,
@@ -216,6 +229,43 @@ function probeChips(probes: EnvironmentProbes | undefined, agent: Agent): ProbeC
return chips.filter((c) => c.ok || probes != null);
}
function parseAtlasSkips(raw: unknown): AtlasSkipView[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out: AtlasSkipView[] = [];
for (const row of raw) {
if (!row || typeof row !== 'object') continue;
const r = row as Record<string, unknown>;
if (typeof r.tier !== 'string' || typeof r.condition !== 'string') continue;
out.push({
tier: r.tier,
condition: r.condition,
reason: typeof r.reason === 'string' ? r.reason : '',
});
}
return out.length ? out : undefined;
}
export function atlasConditionLabel(condition: string): string {
switch (condition) {
case 'defender_on':
return 'Defender on';
case 'no_docker':
return 'no Docker';
case 'av_blocks_exe':
return 'AV blocks exe';
case 'goos=windows':
return 'Windows';
case 'goos=linux':
return 'Linux';
default:
return condition.replace(/_/g, ' ');
}
}
export function atlasSkipDisplayLabel(skip: AtlasSkipView): string {
return `${formatLotlTierLabel(skip.tier)} (${atlasConditionLabel(skip.condition)})`;
}
function spreadCapabilities(agent: Agent): string[] {
const caps = agent.capabilities;
const out: string[] = [];
@@ -303,8 +353,13 @@ function buildOnionRows(
skipped: string[],
attempts: TierAttempt[],
activeTier?: string,
atlasSkips: AtlasSkipView[] = [],
): OnionTierRow[] {
const skippedSet = new Set(skipped.map((s) => s.toLowerCase()));
const atlasByTier = new Map<string, AtlasSkipView>();
for (const skip of atlasSkips) {
atlasByTier.set(skip.tier.toLowerCase(), skip);
}
const okSet = new Set(attempts.filter((a) => a.ok).map((a) => a.tier.toLowerCase()));
const failSet = new Set(attempts.filter((a) => !a.ok).map((a) => a.tier.toLowerCase()));
const active = activeTier?.toLowerCase();
@@ -313,12 +368,15 @@ function buildOnionRows(
const fullOrder = [
...order,
...skipped.filter((s) => !orderLower.has(s.toLowerCase())),
...atlasSkips.map((s) => s.tier).filter((s) => !orderLower.has(s.toLowerCase()) && !skippedSet.has(s.toLowerCase())),
];
return fullOrder.map((tier, i) => {
const key = tier.toLowerCase();
const atlas = atlasByTier.get(key);
let status: OnionTierRow['status'] = 'neutral';
if (active && key === active) status = 'active';
else if (atlas) status = 'skipped_by_atlas';
else if (skippedSet.has(key)) status = 'skipped';
else if (okSet.has(key)) status = 'done';
else if (failSet.has(key)) status = 'failed';
@@ -329,6 +387,7 @@ function buildOnionRows(
tier,
label: formatLotlTierLabel(tier),
status,
atlasCondition: atlas?.condition,
};
});
}
@@ -367,6 +426,7 @@ export function buildAccessDepthModel(
): AccessDepthModel {
const attempts = diagnostics?.lotl_attempts ?? agent.lotl_attempts ?? [];
const activeTier = diagnostics?.lotl_tier ?? agent.lotl_tier;
const atlasSkips = diagnostics?.atlas_skips ?? [];
const { order, skipped, source } = resolveMiningOrder(diagnostics, policy, agent);
const pending = computePendingTiers(order, skipped, attempts);
const inProgressTier = detectInProgress(agent, attempts, pending, activeTier);
@@ -402,7 +462,7 @@ export function buildAccessDepthModel(
inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined,
pendingTiers: pending,
pendingLabels: pending.map(formatLotlTierLabel),
miningOnion: buildOnionRows(order, skipped, attempts, activeTier),
miningOnion: buildOnionRows(order, skipped, attempts, activeTier, atlasSkips),
spreadOnion: spreadOrder.map((tier, i) => ({
index: i + 1,
tier,
@@ -414,6 +474,9 @@ export function buildAccessDepthModel(
adaptiveActive: source === 'adaptive',
strategyReasoning: diagnostics?.strategy_reasoning ?? diagnostics?.adaptive_strategy?.reasoning ?? [],
adaptiveConfidence: diagnostics?.adaptive_strategy?.confidence,
atlasSkips,
phenotypeSource: agent.inherited_phenotype?.source_agent_name,
phenotypeSpreadLane: agent.inherited_phenotype?.spread_lane,
};
}

View File

@@ -6,6 +6,7 @@ import {
buildLotlTimelineModel,
resolveLotlTierOrder,
} from '../help/lotlTimeline';
import { clearanceTimelineSummary, type ClearanceEventRecord } from '../help/clearance';
import { parseAccessDepthServerPolicy } from '../help/accessDepth';
import type { AIDecisionRecord } from '../types';
import LotlFleetOverview from '../components/Lotl/LotlFleetOverview';
@@ -19,7 +20,16 @@ export default function LotlTimelinePage() {
const [searchParams, setSearchParams] = useSearchParams();
const [tierOrder, setTierOrder] = useState<string[]>(() => resolveLotlTierOrder());
const [aiControlEnabled, setAiControlEnabled] = useState(false);
const [lastDecision, setLastDecision] = useState<{ response?: string; commands?: string; ts?: string } | null>(null);
const [lastDecision, setLastDecision] = useState<{
response?: string;
commands?: string;
ts?: string;
court_session?: boolean;
prosecutor_snippet?: string;
defender_snippet?: string;
judge_verdict?: string;
} | null>(null);
const [clearanceEvents, setClearanceEvents] = useState<ClearanceEventRecord[]>([]);
const paramAgentId = searchParams.get('agent') ?? '';
@@ -60,6 +70,10 @@ export default function LotlTimelinePage() {
response: row.response,
commands: row.commands_executed,
ts: row.ts,
court_session: row.court_session,
prosecutor_snippet: row.prosecutor_snippet,
defender_snippet: row.defender_snippet,
judge_verdict: row.judge_verdict,
});
} else {
setLastDecision(null);
@@ -73,6 +87,25 @@ export default function LotlTimelinePage() {
};
}, [selectedAgent, aiControlEnabled]);
useEffect(() => {
if (!selectedAgent) {
setClearanceEvents([]);
return;
}
let cancelled = false;
api
.getClearanceEvents(selectedAgent.id, 20)
.then((rows) => {
if (!cancelled) setClearanceEvents(rows ?? []);
})
.catch(() => {
if (!cancelled) setClearanceEvents([]);
});
return () => {
cancelled = true;
};
}, [selectedAgent]);
const selectAgent = useCallback(
(agentId: string) => {
setSearchParams({ agent: agentId }, { replace: true });
@@ -88,6 +121,8 @@ export default function LotlTimelinePage() {
selectedAgent,
tierOrder,
selectedAgent.lotl_attempts ?? [],
[],
selectedAgent.atlas_skips ?? [],
);
}, [selectedAgent, tierOrder]);
@@ -124,27 +159,81 @@ export default function LotlTimelinePage() {
</div>
) : timelineModel ? (
<>
<LotlTierTimeline model={timelineModel} agentName={selectedAgent.name} />
<LotlTierTimeline
model={timelineModel}
agentName={selectedAgent.name}
clonedFrom={selectedAgent.inherited_phenotype?.source_agent_name}
/>
{clearanceEvents.length > 0 && (
<div className="lotl-clearance-events">
<div className="lotl-ai-decision-label">CLEARANCE HISTORY</div>
<ul className="lotl-clearance-list">
{clearanceEvents.map((ev) => (
<li key={ev.id}>{clearanceTimelineSummary(ev)}</li>
))}
</ul>
</div>
)}
{aiEnabled && lastDecision && (
<div className="lotl-ai-decision">
<div className="lotl-ai-decision-label">LAST AI DECISION</div>
{lastDecision.response && (
<div style={{ color: 'var(--text-muted)' }}>{lastDecision.response}</div>
<div className={`lotl-ai-decision${lastDecision.court_session ? ' lotl-ai-decision--court' : ''}`}>
<div className="lotl-ai-decision-label">
{lastDecision.court_session ? 'SINGULAR MACHINE COURT' : 'LAST AI DECISION'}
</div>
{lastDecision.court_session ? (
<>
{lastDecision.prosecutor_snippet && (
<div className="lotl-court-role">
<span className="lotl-court-role-label">Prosecutor</span>
<div className="lotl-court-role-text">{lastDecision.prosecutor_snippet}</div>
</div>
)}
{lastDecision.defender_snippet && (
<div className="lotl-court-role">
<span className="lotl-court-role-label">Defender</span>
<div className="lotl-court-role-text">{lastDecision.defender_snippet}</div>
</div>
)}
{(lastDecision.judge_verdict || lastDecision.response) && (
<div className="lotl-court-role">
<span className="lotl-court-role-label">Judge</span>
<div className="lotl-court-role-text">
{lastDecision.judge_verdict || lastDecision.response}
</div>
</div>
)}
</>
) : (
lastDecision.response && (
<div style={{ color: 'var(--text-muted)' }}>{lastDecision.response}</div>
)
)}
{lastDecision.commands && (
<div style={{ marginTop: '0.25rem', fontSize: '0.68rem', color: '#7dffaa' }}>
{lastDecision.commands}
</div>
<div className="lotl-ai-decision-commands">{lastDecision.commands}</div>
)}
{lastDecision.ts && (
<div style={{ marginTop: '0.25rem', fontSize: '0.65rem', color: 'var(--text-muted)' }}>
<div className="lotl-ai-decision-ts">
{new Date(lastDecision.ts).toLocaleString()}
</div>
)}
</div>
)}
{clearanceEvents.length > 0 && (
<div className="lotl-clearance-timeline">
<div className="lotl-ai-decision-label">CLEARANCE EVENTS</div>
<ul className="lotl-clearance-event-list">
{clearanceEvents.map((ev) => (
<li key={ev.id} className="lotl-clearance-event-row">
<span className="lotl-clearance-event-summary">{clearanceTimelineSummary(ev)}</span>
<span className="lotl-clearance-event-ts">{new Date(ev.ts).toLocaleString()}</span>
</li>
))}
</ul>
</div>
)}
<p className="lotl-timeline-links">
Data sources: Crucible {' '}
<Link to={`/crucible?agent=${encodeURIComponent(selectedAgent.id)}`}>Access Depth panel</Link>

View File

@@ -98,12 +98,29 @@ export interface Agent {
lotl_tier?: string;
/** Per-tier attempt history from agent TierReport. */
lotl_attempts?: import('./lotl').TierAttempt[];
/** Fleet failure atlas hard subtree skips. */
atlas_skips?: { tier: string; condition: string; reason: string }[];
/** Read-only vuln probe findings (LOTL recon tier). */
vuln_findings?: import('./recon').VulnFinding[];
vuln_risk_score?: number;
/** Last successful discover_and_join deploy lane (winrm, smb, gpo, docker, …). */
join_lane?: string;
/** Session security clearance L0L4 (live from server). */
clearance_level?: number;
/** Cloned fleet phenotype from a sibling with the same host fingerprint. */
inherited_phenotype?: InheritedPhenotype;
}
export interface InheritedPhenotype {
source_agent_name: string;
fingerprint?: string;
spread_lane?: string;
tier_order?: string[];
active_tier?: string;
peak_hashrate?: number;
}
export type { LOTLTier, TierAttempt, TierReport } from './lotl';
@@ -444,6 +461,10 @@ export interface AIDecisionRecord {
response?: string;
commands_executed?: string;
ts?: string;
court_session?: boolean;
prosecutor_snippet?: string;
defender_snippet?: string;
judge_verdict?: string;
}
export interface EarningsEstimate {