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 @@ + + +
+
+
+
Live Scan
+
Agent Progress
+
+
+ + +
+
+ +
+ +
+
+
+
Operators
+
0 active
+
+
+
+ +
+
+
Task Queue
+
0 tasks
+
+
+
+
+ +
+
+
Reasoning And Tool Stream
+
0 events
+
+
+
+
+
@@ -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. */