diff --git a/docs/index.html b/docs/index.html
index 43ac63c..b7806c0 100755
--- a/docs/index.html
+++ b/docs/index.html
@@ -295,6 +295,11 @@
.page { display: none; }
.page.active { display: block; animation: fadeIn 0.3s ease; }
+ .live-scan-grid { display: grid; grid-template-columns: minmax(260px, 0.9fr) minmax(360px, 1.4fr); gap: 14px; align-items: start; }
+
+ @media (max-width: 900px) {
+ .live-scan-grid { grid-template-columns: 1fr; }
+ }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
@@ -3305,6 +3310,9 @@
@@ -5981,6 +6031,14 @@ tempest.operators.
spawn(
'scan
if (data.operators) {
this._updateOperatorStatusUI(data.operators);
}
+ window.updateLiveScanStatus?.(data);
+ } catch {}
+ });
+
+ this._eventSource.addEventListener('scan:progress', (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ window.recordLiveScanEvent?.(data);
} catch {}
});
@@ -6909,13 +6967,205 @@ Correlating findings across agents, identifying patterns, producing actionable i
}));
}
+ const LiveScanState = {
+ status: null,
+ events: [],
+ seen: new Set()
+ };
+
+ function liveScanEscape(value) {
+ return String(value ?? '').replace(/[&<>"']/g, ch => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ }[ch]));
+ }
+
+ function liveScanTime(ts) {
+ const date = ts ? new Date(ts) : new Date();
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ }
+
+ function liveScanStatusColor(status) {
+ const s = String(status || '').toLowerCase();
+ if (s === 'completed' || s === 'idle') return '#00ff88';
+ if (s === 'failed' || s === 'burned') return '#ff4444';
+ if (s === 'running' || s === 'tasked' || s === 'executing' || s === 'in_progress') return '#00aaff';
+ if (s === 'pending' || s === 'cooldown') return '#ffaa00';
+ return 'var(--text-secondary)';
+ }
+
+ function liveScanKindLabel(kind) {
+ return ({
+ task_started: 'START',
+ thinking: 'THINK',
+ tool_call: 'TOOL',
+ tool_result: 'RESULT',
+ task_completed: 'DONE',
+ task_failed: 'FAIL'
+ })[kind] || String(kind || 'EVENT').toUpperCase();
+ }
+
+ function liveScanMergeEvents(events) {
+ if (!Array.isArray(events)) return;
+ events.forEach(ev => {
+ const id = ev?.id || `${ev?.timestamp || Date.now()}-${ev?.operatorId || ''}-${ev?.kind || ''}-${ev?.detail || ''}`;
+ if (LiveScanState.seen.has(id)) return;
+ LiveScanState.seen.add(id);
+ LiveScanState.events.push({ ...ev, id });
+ });
+ LiveScanState.events.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
+ if (LiveScanState.events.length > 300) {
+ const trimmed = LiveScanState.events.splice(0, LiveScanState.events.length - 300);
+ trimmed.forEach(ev => LiveScanState.seen.delete(ev.id));
+ }
+ }
+
+ window.recordLiveScanEvent = function recordLiveScanEvent(event) {
+ liveScanMergeEvents([event]);
+ renderLiveScanPage();
+ };
+
+ window.updateLiveScanStatus = function updateLiveScanStatus(status) {
+ LiveScanState.status = status || null;
+ liveScanMergeEvents(status?.progress);
+ renderLiveScanPage();
+ };
+
+ window.refreshLiveScanPage = async function refreshLiveScanPage() {
+ const status = await T3MP3ST_API.get('/api/mission/status');
+ window.updateLiveScanStatus(status);
+ };
+
+ window.clearLiveScanFeed = function clearLiveScanFeed() {
+ LiveScanState.events = [];
+ LiveScanState.seen = new Set();
+ renderLiveScanPage();
+ };
+
+ function renderLiveScanPage() {
+ const page = document.getElementById('page-live-scan');
+ if (!page) return;
+
+ const status = LiveScanState.status || {};
+ const active = status.active ?? status.running ?? false;
+ const mission = status.mission || {};
+ const operators = Array.isArray(status.operators?.details) ? status.operators.details : [];
+ const operatorSummary = status.operators?.summary || status.operators || {};
+ const tasks = Array.isArray(status.tasks) ? status.tasks : [];
+ const events = LiveScanState.events.slice().reverse();
+ const currentPhase = mission.currentPhase || status.currentPhase || 'none';
+ const progress = typeof mission.progress === 'number' ? `${Math.round(mission.progress)}%` : '-';
+
+ const summary = document.getElementById('liveScanSummary');
+ if (summary) {
+ const cells = [
+ ['State', status.paused ? 'Paused' : (active ? 'Running' : 'Stopped'), active ? '#00ff88' : '#ffaa00'],
+ ['Phase', currentPhase, '#00aaff'],
+ ['Progress', progress, '#00ff88'],
+ ['Operators', String(operatorSummary.total ?? operators.length ?? 0), '#ffaa00'],
+ ['Tasks', String(tasks.length), '#00aaff'],
+ ['Stall', status.stallReason || '-', status.stallReason ? '#ff4444' : 'var(--text-secondary)']
+ ];
+ summary.innerHTML = cells.map(([label, value, color]) => `
+
+
${liveScanEscape(label)}
+
${liveScanEscape(value)}
+
+ `).join('');
+ }
+
+ const opCount = document.getElementById('liveScanOperatorCount');
+ if (opCount) opCount.textContent = `${operatorSummary.busy ?? operators.filter(op => ['tasked', 'executing'].includes(op.status)).length ?? 0} busy`;
+ const opEl = document.getElementById('liveScanOperators');
+ if (opEl) {
+ if (!operators.length) {
+ opEl.innerHTML = 'No operator details available yet.
';
+ } else {
+ opEl.innerHTML = operators.map(op => {
+ const statusColor = liveScanStatusColor(op.status);
+ return `
+
+
+
${liveScanEscape(op.callsign || op.id)}
+
${liveScanEscape(op.archetype || 'operator')}
+
${Number(op.completedTasks || 0)} done ยท ${Number(op.failedTasks || 0)} failed ยท risk ${Number(op.detectionRisk || 0)}
+
+
${liveScanEscape(op.status || 'unknown')}
+
+ `;
+ }).join('');
+ }
+ }
+
+ const taskCount = document.getElementById('liveScanTaskCount');
+ if (taskCount) taskCount.textContent = `${tasks.length} tasks`;
+ const taskEl = document.getElementById('liveScanTasks');
+ if (taskEl) {
+ if (!tasks.length) {
+ taskEl.innerHTML = 'No mission tasks are queued.
';
+ } else {
+ taskEl.innerHTML = tasks.map(task => {
+ const color = liveScanStatusColor(task.status);
+ const output = task.result?.error || task.result?.output || '';
+ return `
+
+
+
${liveScanEscape(task.name || task.id)}
+
${liveScanEscape(task.phase || '-')} ยท ${liveScanEscape(task.operatorType || '-')} ${task.assignedTo ? 'ยท ' + liveScanEscape(task.assignedTo) : ''}
+ ${output ? `
${liveScanEscape(output).slice(0, 260)}
` : ''}
+
+
${liveScanEscape(task.status || 'unknown')}
+
+ `;
+ }).join('');
+ }
+ }
+
+ const feedCount = document.getElementById('liveScanFeedCount');
+ if (feedCount) feedCount.textContent = `${LiveScanState.events.length} events`;
+ const feed = document.getElementById('liveScanFeed');
+ if (feed) {
+ if (!events.length) {
+ feed.innerHTML = 'No live reasoning events yet. Start or refresh a scan to populate this stream.
';
+ } else {
+ feed.innerHTML = events.map(ev => {
+ const color = ev.kind === 'task_failed' || ev.success === false ? '#ff6666'
+ : ev.kind === 'tool_call' ? '#ffaa00'
+ : ev.kind === 'tool_result' ? '#00aaff'
+ : ev.kind === 'thinking' ? '#cfd6e6'
+ : '#00ff88';
+ const sourceBadge = ev.source === 'backend_seeded'
+ ? 'backend-seeded'
+ : '';
+ return `
+
+
${liveScanTime(ev.timestamp)}
+
${liveScanEscape(liveScanKindLabel(ev.kind))}
+
${liveScanEscape(ev.callsign || ev.archetype || ev.operatorId || '-')}
+
+
${liveScanEscape(ev.taskName || ev.toolName || '-')}${sourceBadge}
+
${liveScanEscape(ev.detail || '')}
+
+
+ `;
+ }).join('');
+ }
+ }
+ }
+
+ setInterval(() => {
+ if (document.getElementById('page-live-scan')?.classList.contains('active')) {
+ window.refreshLiveScanPage?.();
+ }
+ }, 3000);
+
// Navigation
function navigateTo(page) {
document.querySelectorAll('.nav-item').forEach(i => i.classList.toggle('active', i.dataset.page === page));
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${page}`));
- const titles = { warroom: 'War Room', operators: 'Operatives', evidence: 'Evidence Vault', arsenal: 'Arsenal', terminal: 'Terminal', benchmarks: 'Benchmarks', configs: 'Config Library', 'ctf-range': 'CTF Range', general: 'Op Admiral', selfimprove: 'Self-Improvement', settings: 'Settings', about: 'About' };
+ const titles = { warroom: 'War Room', 'live-scan': 'Live Scan', operators: 'Operatives', evidence: 'Evidence Vault', arsenal: 'Arsenal', terminal: 'Terminal', benchmarks: 'Benchmarks', configs: 'Config Library', 'ctf-range': 'CTF Range', general: 'Op Admiral', selfimprove: 'Self-Improvement', settings: 'Settings', about: 'About' };
document.getElementById('pageTitle').textContent = titles[page] || 'War Room';
document.getElementById('sidebar').classList.remove('open');
+ if (page === 'live-scan') setTimeout(() => window.refreshLiveScanPage?.(), 50);
if (page === 'operators' && typeof window.loadOperativesRoster === 'function') setTimeout(window.loadOperativesRoster, 80);
if (page === 'evidence' && typeof window.hydrateFindings === 'function') setTimeout(window.hydrateFindings, 60);
if (page === 'selfimprove' && typeof window.renderSelfImprove === 'function') setTimeout(window.renderSelfImprove, 60);
diff --git a/src/agent/index.ts b/src/agent/index.ts
index 092b55a..6eb639d 100755
--- a/src/agent/index.ts
+++ b/src/agent/index.ts
@@ -71,8 +71,8 @@ export interface AgentResult {
export interface AgentEvents {
'agent:step': AgentStep;
- 'agent:tool_call': { name: string; args: Record };
- 'agent:tool_result': { name: string; result: ToolResult };
+ 'agent:tool_call': { name: string; args: Record; source?: 'agent' | 'backend_seeded' };
+ 'agent:tool_result': { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
'agent:thinking': { content: string };
'agent:complete': AgentResult;
'agent:error': { error: Error; step: number };
@@ -133,6 +133,39 @@ export class AgentLoop extends EventEmitter {
{ role: 'user', content: this.buildTaskPrompt(task, target, toolDefs, sourceContext, sharedContext) },
];
+ const bootstrapCall = this.getLocalReconBootstrap(task, target, toolDefs);
+ if (bootstrapCall) {
+ const toolStep = await this.executeTool(bootstrapCall, target, -1);
+ steps.push(toolStep);
+ seenCalls.set(
+ `${bootstrapCall.name}:${JSON.stringify(bootstrapCall.arguments || {})}`,
+ String(toolStep.toolResult?.output || toolStep.toolResult?.error || 'no output').replace(/\s+/g, ' ').slice(0, 160)
+ );
+
+ if (toolStep.toolResult?.findings) {
+ const out = String(toolStep.toolResult.output ?? '').slice(0, 4000);
+ for (const tf of toolStep.toolResult.findings) {
+ allFindings.push({ ...tf, provenance: 'tool', toolName: bootstrapCall.name, toolOutput: out || tf.details });
+ }
+ }
+
+ messages.push({
+ role: 'assistant',
+ content: 'Running an initial low-noise recon check before planning deeper actions.',
+ toolCalls: [bootstrapCall],
+ });
+ messages.push({
+ role: 'tool',
+ content: this.formatToolResult(toolStep.toolResult),
+ toolCallId: bootstrapCall.id,
+ name: bootstrapCall.name,
+ });
+ messages.push({
+ role: 'user',
+ content: 'Use the bootstrap observation above as your first tool observation. Continue through the normal ACTION CONTRACT: request another listed tool only if it will materially improve the evidence; otherwise provide the final recon debrief.',
+ });
+ }
+
for (let i = 0; i < this.options.maxIterations; i++) {
try {
// Ask the LLM what to do next
@@ -298,6 +331,38 @@ export class AgentLoop extends EventEmitter {
return result;
}
+ private getLocalReconBootstrap(
+ task: Task,
+ target: Target | undefined,
+ tools: LLMToolDefinition[]
+ ): LLMToolCall | undefined {
+ if (this.llm.getProvider() !== 'local-agent') return undefined;
+ if (!target?.address) return undefined;
+ if (task.phase !== 'reconnaissance' && task.operatorType !== 'recon') return undefined;
+
+ const hasTool = (name: string) => tools.some((tool) => tool.name === name);
+ const taskName = task.name.toLowerCase();
+ const id = `local-agent-bootstrap-${Date.now()}`;
+
+ if (taskName.includes('dns') && hasTool('dns_lookup')) {
+ return { id, name: 'dns_lookup', arguments: { domain: target.address, type: 'A' } };
+ }
+
+ if (taskName.includes('port') && hasTool('port_scan')) {
+ return { id, name: 'port_scan', arguments: { target: target.address, ports: '80,443', timeout: 2000 } };
+ }
+
+ if ((taskName.includes('web') || taskName.includes('fingerprint')) && hasTool('header_analysis')) {
+ return { id, name: 'header_analysis', arguments: { url: `https://${target.address}` } };
+ }
+
+ if ((taskName.includes('content') || taskName.includes('discover')) && hasTool('robots_txt_fetch')) {
+ return { id, name: 'robots_txt_fetch', arguments: { url: `https://${target.address}` } };
+ }
+
+ return undefined;
+ }
+
/**
* Execute a single tool call via the Arsenal
*/
@@ -306,7 +371,8 @@ export class AgentLoop extends EventEmitter {
target: Target | undefined,
iteration: number
): Promise {
- this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments });
+ const source = iteration < 0 ? 'backend_seeded' : 'agent';
+ this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments, source });
let toolResult: ToolResult;
try {
@@ -330,7 +396,7 @@ export class AgentLoop extends EventEmitter {
};
}
- this.emit('agent:tool_result', { name: toolCall.name, result: toolResult });
+ this.emit('agent:tool_result', { name: toolCall.name, result: toolResult, source });
return {
iteration,
diff --git a/src/index.ts b/src/index.ts
index e975767..04289ca 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -212,6 +212,8 @@ import type {
OperatorArchetype,
CommandEvents,
Finding,
+ ScanProgressEvent,
+ Task,
} from './types/index.js';
// Re-export commonly used types
@@ -271,6 +273,7 @@ import {
const DEFAULT_AGENT_MAX_ITERATIONS = 15;
const LOCAL_AGENT_MAX_ITERATIONS = Number(process.env.T3MP3ST_LOCAL_AGENT_MAX_ITERATIONS || 30);
+const MAX_PROGRESS_EVENTS = 300;
/**
* TEMPEST Command - Main orchestration controller
@@ -544,6 +547,40 @@ export class TempestCommand extends EventEmitter {
* Setup event forwarding for an operator
*/
private setupOperatorEvents(operator: OperatorAgent): void {
+ operator.on('task:started', ({ task }) => {
+ this.recordProgress('task_started', operator, task, `Started ${task.name}`);
+ });
+
+ operator.on('task:completed', ({ task, result }) => {
+ const findings = result.findings?.length ? ` Findings: ${result.findings.join(', ')}` : '';
+ this.recordProgress(
+ 'task_completed',
+ operator,
+ task,
+ `${result.success === false ? 'Finished unsuccessfully' : 'Completed'} ${task.name}.${findings}`,
+ { success: result.success !== false }
+ );
+ });
+
+ operator.on('task:failed', ({ task, error }) => {
+ this.recordProgress('task_failed', operator, task, error, { success: false });
+ });
+
+ operator.on('agent:thinking', ({ task, content }) => {
+ this.recordProgress('thinking', operator, task, content);
+ });
+
+ operator.on('agent:tool_call', ({ task, name, args, source }) => {
+ this.recordProgress('tool_call', operator, task, `${name} ${JSON.stringify(args || {})}`, { toolName: name, source });
+ });
+
+ operator.on('agent:tool_result', ({ task, name, result, source }) => {
+ const detail = result.success
+ ? (result.output || 'Tool completed')
+ : (result.error || result.output || 'Tool failed');
+ this.recordProgress('tool_result', operator, task, detail, { toolName: name, success: result.success, source });
+ });
+
operator.on('finding:discovered', ({ finding }) => {
this.vault.addFinding(finding);
this.emit('finding:discovered', { finding, operatorId: operator.id });
@@ -628,6 +665,34 @@ export class TempestCommand extends EventEmitter {
});
}
+ private recordProgress(
+ kind: ScanProgressEvent['kind'],
+ operator: OperatorAgent,
+ task: Task | undefined,
+ detail: string,
+ extra: Partial> = {}
+ ): void {
+ const compact = String(detail || '').replace(/\s+/g, ' ').trim().slice(0, 2000);
+ const event: ScanProgressEvent = {
+ id: `progress-${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ timestamp: Date.now(),
+ kind,
+ operatorId: operator.id,
+ callsign: operator.callsign,
+ archetype: operator.archetype,
+ taskId: task?.id,
+ taskName: task?.name,
+ detail: compact,
+ ...extra,
+ };
+
+ this.progressEvents.push(event);
+ if (this.progressEvents.length > MAX_PROGRESS_EVENTS) {
+ this.progressEvents.splice(0, this.progressEvents.length - MAX_PROGRESS_EVENTS);
+ }
+ this.emit('scan:progress', event);
+ }
+
/**
* Parse a finding and update the target's services/vulnerabilities.
* This is the intelligence pipeline that feeds data between phases.
@@ -826,6 +891,8 @@ export class TempestCommand extends EventEmitter {
* CLI work; it only fires on truly-hung dispatches. Override via
* T3MP3ST_TASK_TIMEOUT_MS.
*/
+ private progressEvents: ScanProgressEvent[] = [];
+
/**
* Resolve the dispatch timeout from the environment, falling back to the
* provider-specific default. Guards against a non-numeric / non-positive override.
@@ -951,6 +1018,7 @@ export class TempestCommand extends EventEmitter {
// Auto-spawn an operator if none exists for this archetype
if (!operator) {
+ if (this.llm.getProvider() === 'local-agent') continue;
const allOps = this.cell.getAllOperators();
const archetypeCount = allOps.filter(op => op.archetype === task.operatorType).length;
// Spawn up to 3 operators per archetype for parallelism
@@ -1099,6 +1167,9 @@ export class TempestCommand extends EventEmitter {
* Auto-spawn operators needed for a given kill chain phase
*/
private autoSpawnForPhase(phase: KillChainPhase): void {
+ if (this.llm.getProvider() === 'local-agent') {
+ return;
+ }
const phaseOperators: Record = {
[KillChainPhase.RECON]: ['recon'],
[KillChainPhase.WEAPONIZE]: ['scanner'],
@@ -1139,6 +1210,7 @@ export class TempestCommand extends EventEmitter {
this.on('detection:triggered', (data) => broadcast('detection', data));
this.on('mission:phase_changed', (data) => broadcast('phase_changed', data));
this.on('approval:decision', (data) => broadcast('arsenal.approval', data));
+ this.on('scan:progress', (data) => broadcast('scan:progress', data));
this.on('tick', (count) => {
// Broadcast status every 5 ticks to avoid flooding
if (typeof count === 'number' && count % 5 === 0) {
@@ -1232,8 +1304,19 @@ export class TempestCommand extends EventEmitter {
opsec: ReturnType;
activeMission: string | null;
stallReason: string | null;
+ progress: ScanProgressEvent[];
+ tasks: Array<{
+ id: string;
+ name: string;
+ phase: string;
+ status: string;
+ operatorType: string;
+ assignedTo?: string;
+ result?: { success: boolean; output?: string; error?: string; findings?: string[] };
+ }>;
} {
const activeMission = this.mission.getActiveMission();
+ const taskQueue = this.mission.getTaskQueue();
return {
name: this.name,
@@ -1246,6 +1329,23 @@ export class TempestCommand extends EventEmitter {
opsec: this.opsec.getStats(),
activeMission: activeMission?.id || null,
stallReason: this.stallReason,
+ progress: [...this.progressEvents],
+ tasks: activeMission
+ ? taskQueue.getForMission(activeMission.id).map(task => ({
+ id: task.id,
+ name: task.name,
+ phase: task.phase,
+ status: task.status,
+ operatorType: task.operatorType,
+ assignedTo: task.assignedTo,
+ result: task.result ? {
+ success: task.result.success,
+ output: task.result.output,
+ error: task.result.error,
+ findings: task.result.findings,
+ } : undefined,
+ }))
+ : [],
};
}
diff --git a/src/operators/index.ts b/src/operators/index.ts
index cdb70f5..c7743d4 100755
--- a/src/operators/index.ts
+++ b/src/operators/index.ts
@@ -22,6 +22,7 @@ import {
import type { LLMBackbone } from '../llm/index.js';
import type { Arsenal } from '../arsenal/index.js';
import type { AgentLoop } from '../agent/index.js';
+import type { ToolResult } from '../types/index.js';
import type { Target } from '../types/index.js';
import { OPERATOR_SYSTEM_PROMPTS } from '../prompts/index.js';
import { gateLiveFinding } from '../evidence/gate.js';
@@ -36,6 +37,9 @@ export interface OperatorEvents {
'task:completed': { task: Task; result: TaskResult };
'task:failed': { task: Task; error: string };
'task:decomposed': { parent: Task; subtasks: Task[]; reason: string };
+ 'agent:thinking': { task: Task; content: string };
+ 'agent:tool_call': { task: Task; name: string; args: Record; source?: 'agent' | 'backend_seeded' };
+ 'agent:tool_result': { task: Task; name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' };
'finding:discovered': { finding: Finding };
'finding:gate-blocked': { finding: Finding; reasons: string[] };
'credential:harvested': { credential: Credential };
@@ -422,11 +426,38 @@ export class OperatorAgent extends EventEmitter {
// Pass the white-box source excerpt (if any) so the model sees the target's
// real source alongside the task; empty string keeps black-box behavior.
if (this.agentLoop && this.llm) {
- // [Phase-2] Register as live on the board and pull the shared situation report so this
- // operator sees teammates' verified leads/claims โ it builds on them instead of running blind.
- this.board?.heartbeat(this.id, 'hunting', task.name);
- const sharedContext = this.board?.situationReport(this.id);
- const result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
+ const onThinking = ({ content }: { content: string }): void => {
+ this.emit('agent:thinking', { task, content });
+ };
+ const onToolCall = ({ name, args, source }: { name: string; args: Record; source?: 'agent' | 'backend_seeded' }): void => {
+ this.emit('agent:tool_call', { task, name, args, source });
+ };
+ const onToolResult = ({ name, result, source }: { name: string; result: ToolResult; source?: 'agent' | 'backend_seeded' }): void => {
+ this.emit('agent:tool_result', { task, name, result, source });
+ };
+ const canForwardAgentEvents =
+ typeof this.agentLoop.on === 'function' &&
+ typeof this.agentLoop.off === 'function';
+ if (canForwardAgentEvents) {
+ this.agentLoop.on('agent:thinking', onThinking);
+ this.agentLoop.on('agent:tool_call', onToolCall);
+ this.agentLoop.on('agent:tool_result', onToolResult);
+ }
+
+ let result;
+ try {
+ // [Phase-2] Register as live on the board and pull the shared situation report so this
+ // operator sees teammates' verified leads/claims โ it builds on them instead of running blind.
+ this.board?.heartbeat(this.id, 'hunting', task.name);
+ const sharedContext = this.board?.situationReport(this.id);
+ result = await this.agentLoop.run(task, this.profile.systemPrompt, target, this.whiteboxSource, sharedContext);
+ } finally {
+ if (canForwardAgentEvents) {
+ this.agentLoop.off('agent:thinking', onThinking);
+ this.agentLoop.off('agent:tool_call', onToolCall);
+ this.agentLoop.off('agent:tool_result', onToolResult);
+ }
+ }
// Convert agent findings to operator findings.
// PROVENANCE-HONEST: only a tool-backed finding gets tool-output evidence (the raw
diff --git a/src/server.ts b/src/server.ts
index 271a6ad..19d7967 100755
--- a/src/server.ts
+++ b/src/server.ts
@@ -6293,7 +6293,7 @@ app.post('/api/mission/resume', (_req: Request, res: Response) => {
app.get('/api/mission/status', (_req: Request, res: Response) => {
const cmd = getTempestCommand();
if (!cmd) {
- res.json({ active: false });
+ res.json({ active: false, progress: [], tasks: [] });
return;
}
@@ -6323,6 +6323,8 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
targets: status.targets,
vault: status.vault,
opsec: status.opsec,
+ tasks: status.tasks,
+ progress: status.progress,
findings: findings.map(f => ({
id: f.id,
title: f.title,
diff --git a/src/types/index.ts b/src/types/index.ts
index e19cfa7..7c92650 100755
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -534,6 +534,21 @@ export interface Appendix {
// EVENT TYPES
// =============================================================================
+export interface ScanProgressEvent {
+ id: string;
+ timestamp: number;
+ kind: 'task_started' | 'thinking' | 'tool_call' | 'tool_result' | 'task_completed' | 'task_failed';
+ operatorId: string;
+ callsign: string;
+ archetype: OperatorArchetype;
+ taskId?: string;
+ taskName?: string;
+ toolName?: string;
+ source?: 'agent' | 'backend_seeded';
+ detail: string;
+ success?: boolean;
+}
+
export interface CommandEvents {
'command:started': void;
'command:stopped': void;
@@ -547,6 +562,7 @@ export interface CommandEvents {
'target:owned': { target: Target; operatorId: string };
'detection:triggered': DetectionEvent;
'mission:phase_changed': { missionId: string; phase: KillChainPhase };
+ 'scan:progress': ScanProgressEvent;
'abort:recommended': string;
/** A capability-approval gate decision (allowed/denied) on an intrusive/dangerous tool โ bridged to
* the dashboard's live approval/audit feed. Structural match for arsenal/approval.ts ApprovalRecord. */