-
- {truncatedOutput()}
+ {/* Expanded output */}
+
+
+
+ {displayOutput()}
-
+ 300}>
@@ -69,24 +103,126 @@ export const ToolExecutionBlock: Component = (props) =>
);
};
-// Pending tool (still running)
+/**
+ * PendingToolBlock - Compact single-line display for running tools
+ */
interface PendingToolBlockProps {
tool: PendingTool;
}
export const PendingToolBlock: Component = (props) => {
+ const toolLabel = createMemo(() => {
+ const name = props.tool.name;
+ if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
+ if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
+ if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state') return 'infra';
+ if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
+ if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
+ if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
+ if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
+ if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
+ if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
+ if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
+ if (name.includes('finding')) return 'finding';
+ return name.replace(/^pulse_/, '').replace(/_/g, ' ').substring(0, 12);
+ });
+
return (
-
-
-
-
{props.tool.input}
-
Running
-
+
+ {/* Spinner */}
+
+
+ {/* Tool label */}
+
+ {toolLabel()}
+
+
+ {/* Command - truncated */}
+
+ {props.tool.input.length > 50 ? props.tool.input.substring(0, 50) + '...' : props.tool.input}
+
+
+ );
+};
+
+/**
+ * PendingToolsList - Groups multiple pending tools into a compact list
+ */
+interface PendingToolsListProps {
+ tools: PendingTool[];
+}
+
+export const PendingToolsList: Component
= (props) => {
+ const [expanded, setExpanded] = createSignal(false);
+
+ // If 3 or fewer, show all. Otherwise show collapsed.
+ const shouldCollapse = () => props.tools.length > 3;
+ const visibleTools = () => {
+ if (!shouldCollapse() || expanded()) return props.tools;
+ return props.tools.slice(0, 2);
+ };
+ const hiddenCount = () => props.tools.length - 2;
+
+ return (
+
+
+ {(tool) => }
+
+
+
+
+
+
+ );
+};
+
+/**
+ * ToolExecutionsList - Compact list for multiple completed tools
+ */
+interface ToolExecutionsListProps {
+ tools: ToolExecution[];
+}
+
+export const ToolExecutionsList: Component = (props) => {
+ const [showAll, setShowAll] = createSignal(false);
+
+ // If more than 5 tools, collapse them
+ const shouldCollapse = () => props.tools.length > 5;
+ const visibleTools = () => {
+ if (!shouldCollapse() || showAll()) return props.tools;
+ return props.tools.slice(0, 3);
+ };
+ const hiddenCount = () => props.tools.length - 3;
+
+ // Count successes/failures
+ const stats = createMemo(() => {
+ const success = props.tools.filter(t => t.success).length;
+ const failed = props.tools.length - success;
+ return { success, failed };
+ });
+
+ return (
+
+
+ {(tool) => }
+
+
+
+
+
);
};
diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts
index 1339a9269..b8de41558 100644
--- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts
+++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts
@@ -49,6 +49,7 @@ export function useChat(options: UseChatOptions = {}) {
// Helper to add stream event for chronological display
const addStreamEvent = (msg: ChatMessage, event: StreamDisplayEvent): ChatMessage => {
const events = msg.streamEvents || [];
+
// For content events, merge consecutive content into one
if (event.type === 'content' && events.length > 0) {
const last = events[events.length - 1];
@@ -62,6 +63,21 @@ export function useChat(options: UseChatOptions = {}) {
};
}
}
+
+ // For thinking events, merge consecutive thinking into one
+ if (event.type === 'thinking' && events.length > 0) {
+ const last = events[events.length - 1];
+ if (last.type === 'thinking') {
+ return {
+ ...msg,
+ streamEvents: [
+ ...events.slice(0, -1),
+ { ...last, thinking: (last.thinking || '') + (event.thinking || '') },
+ ],
+ };
+ }
+ }
+
return {
...msg,
streamEvents: [...events, event],
@@ -103,18 +119,31 @@ export function useChat(options: UseChatOptions = {}) {
case 'tool_start': {
const data = event.data as { name: string; input: string };
+ const toolId = generateId(); // Unique ID to track this tool
+ const pendingTool = { name: data.name, input: data.input };
+
+ // Add to streamEvents in chronological position
+ const updated = addStreamEvent(msg, {
+ type: 'pending_tool',
+ pendingTool,
+ toolId,
+ });
+
return {
- ...msg,
- pendingTools: [...(msg.pendingTools || []), { name: data.name, input: data.input }],
+ ...updated,
+ pendingTools: [...(msg.pendingTools || []), { ...pendingTool, id: toolId } as any],
};
}
case 'tool_end': {
const data = event.data as { name: string; input: string; output: string; success: boolean };
const pendingTools = msg.pendingTools || [];
- const matchingIndex = pendingTools.findIndex((t) => t.name === data.name);
- const updatedPending = matchingIndex >= 0
- ? [...pendingTools.slice(0, matchingIndex), ...pendingTools.slice(matchingIndex + 1)]
+ const events = msg.streamEvents || [];
+
+ // Find the matching pending tool (by name, since we may not have ID in the event)
+ const matchingPendingIndex = pendingTools.findIndex((t) => t.name === data.name);
+ const updatedPending = matchingPendingIndex >= 0
+ ? [...pendingTools.slice(0, matchingPendingIndex), ...pendingTools.slice(matchingPendingIndex + 1)]
: pendingTools;
const newToolCall: ToolExecution = {
@@ -124,10 +153,21 @@ export function useChat(options: UseChatOptions = {}) {
success: data.success,
};
- // Add tool to streamEvents for chronological display
- const updated = addStreamEvent(msg, { type: 'tool', tool: newToolCall });
+ // Find the pending_tool event in streamEvents and replace it with completed tool
+ // Search from the end to find the most recent matching pending tool
+ let updatedEvents = [...events];
+ for (let i = events.length - 1; i >= 0; i--) {
+ const evt = events[i];
+ if (evt.type === 'pending_tool' && evt.pendingTool?.name === data.name) {
+ // Replace pending with completed
+ updatedEvents[i] = { type: 'tool', tool: newToolCall };
+ break;
+ }
+ }
+
return {
- ...updated,
+ ...msg,
+ streamEvents: updatedEvents,
pendingTools: updatedPending,
toolCalls: [...(msg.toolCalls || []), newToolCall],
};
diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx
index 34d68d0b6..a4adbcd3e 100644
--- a/frontend-modern/src/components/AI/Chat/index.tsx
+++ b/frontend-modern/src/components/AI/Chat/index.tsx
@@ -1,4 +1,4 @@
-import { Component, Show, createSignal, onMount, For } from 'solid-js';
+import { Component, Show, createSignal, onMount, For, createMemo } from 'solid-js';
import { OpenCodeAPI, type ChatSession } from '@/api/opencode';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
@@ -10,6 +10,12 @@ interface AIChatProps {
onClose: () => void;
}
+/**
+ * AIChat - Main chat panel component.
+ *
+ * Provides a terminal-like chat experience with clear status indicators,
+ * session management, and streaming response display.
+ */
export const AIChat: Component = (props) => {
// UI state
const [isOpen] = createSignal(true);
@@ -20,6 +26,30 @@ export const AIChat: Component = (props) => {
// Chat hook
const chat = useChat();
+ // Compute current status for display
+ const currentStatus = createMemo(() => {
+ if (!chat.isLoading()) return null;
+
+ const messages = chat.messages();
+ const lastMessage = messages[messages.length - 1];
+
+ if (!lastMessage || lastMessage.role !== 'assistant') {
+ return { type: 'thinking', text: 'Thinking...' };
+ }
+
+ if (lastMessage.pendingTools && lastMessage.pendingTools.length > 0) {
+ const tool = lastMessage.pendingTools[0];
+ const toolName = tool.name.replace(/^pulse_/, '').replace(/_/g, ' ');
+ return { type: 'tool', text: `Running ${toolName}...` };
+ }
+
+ if (lastMessage.isStreaming) {
+ return { type: 'generating', text: 'Generating response...' };
+ }
+
+ return { type: 'thinking', text: 'Thinking...' };
+ });
+
// Load sessions on mount
onMount(async () => {
try {
@@ -79,38 +109,37 @@ export const AIChat: Component = (props) => {
};
// Empty state for approval (not used with OpenCode but keeping interface)
- const handleApprove = (_messageId: string, _approval: PendingApproval) => {};
- const handleSkip = (_messageId: string, _toolId: string) => {};
+ const handleApprove = (_messageId: string, _approval: PendingApproval) => { };
+ const handleSkip = (_messageId: string, _toolId: string) => { };
return (
{/* Header */}
-
+
-
+
-
AI Assistant
-
+
AI Assistant
+
Powered by OpenCode
-
+
{/* Session picker */}
-
+
@@ -577,6 +709,14 @@ export const AgentProfilesPanel: Component = () => {
+
+ {/* Suggest Profile Modal */}
+
+ setShowSuggestModal(false)}
+ onSuggestionAccepted={handleSuggestionAccepted}
+ />
+
diff --git a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx
index bc7e4439e..5fe4a7fd4 100644
--- a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx
+++ b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx
@@ -14,6 +14,7 @@ import Download from 'lucide-solid/icons/download';
import CheckCircle from 'lucide-solid/icons/check-circle';
import XCircle from 'lucide-solid/icons/x-circle';
import AlertTriangle from 'lucide-solid/icons/alert-triangle';
+import Sparkles from 'lucide-solid/icons/sparkles';
// Type definitions
interface DiagnosticsNode {
@@ -111,6 +112,18 @@ interface AlertsDiagnostic {
notes?: string[];
}
+interface OpenCodeDiagnostic {
+ enabled: boolean;
+ running: boolean;
+ healthy: boolean;
+ port?: number;
+ url?: string;
+ model?: string;
+ mcpConnected: boolean;
+ mcpToolCount?: number;
+ notes?: string[];
+}
+
interface DiagnosticsData {
version: string;
runtime: string;
@@ -122,6 +135,7 @@ interface DiagnosticsData {
apiTokens?: APITokenDiagnostic | null;
dockerAgents?: DockerAgentDiagnostic | null;
alerts?: AlertsDiagnostic | null;
+ openCode?: OpenCodeDiagnostic | null;
discovery?: DiscoveryDiagnostic | null;
errors: string[];
}
@@ -650,6 +664,51 @@ export const DiagnosticsPanel: Component = () => {
+
+ {/* OpenCode AI Status */}
+
+
+
+
+
+
+
+
AI Assistant
+
OpenCode Sidecar
+
+
+
+
+
+
+
+
+
+
+
+
MCP Connection
+
+ {diagnosticsData()?.openCode?.mcpConnected ?
+ :
+
+ }
+
+ {diagnosticsData()?.openCode?.mcpConnected ? 'Connected' : 'Disconnected'}
+
+
+
+ 0}>
+
+
+ {(note) => - {note}
}
+
+
+
+
+
{/* Errors Section */}
@@ -671,8 +730,8 @@ export const DiagnosticsPanel: Component = () => {
-
-
+
+
);
};
diff --git a/frontend-modern/src/components/Settings/SuggestProfileModal.tsx b/frontend-modern/src/components/Settings/SuggestProfileModal.tsx
new file mode 100644
index 000000000..c7b930489
--- /dev/null
+++ b/frontend-modern/src/components/Settings/SuggestProfileModal.tsx
@@ -0,0 +1,249 @@
+import { Component, createSignal, Show, For } from 'solid-js';
+import { AgentProfilesAPI, type ProfileSuggestion } from '@/api/agentProfiles';
+import { notificationStore } from '@/stores/notifications';
+import { logger } from '@/utils/logger';
+import Sparkles from 'lucide-solid/icons/sparkles';
+import AlertCircle from 'lucide-solid/icons/alert-circle';
+import Check from 'lucide-solid/icons/check';
+import Loader2 from 'lucide-solid/icons/loader-2';
+
+interface SuggestProfileModalProps {
+ onClose: () => void;
+ onSuggestionAccepted: (suggestion: ProfileSuggestion) => void;
+}
+
+export const SuggestProfileModal: Component
= (props) => {
+ const [prompt, setPrompt] = createSignal('');
+ const [loading, setLoading] = createSignal(false);
+ const [error, setError] = createSignal(null);
+ const [suggestion, setSuggestion] = createSignal(null);
+
+ // Example prompts for inspiration
+ const examplePrompts = [
+ 'Create a profile for production servers with minimal logging',
+ 'Profile for Docker hosts that need container monitoring',
+ 'Kubernetes monitoring profile with all pods visible',
+ 'Development environment profile with debug logging',
+ ];
+
+ const handleSubmit = async () => {
+ const userPrompt = prompt().trim();
+ if (!userPrompt) {
+ setError('Please enter a description for the profile you need');
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ setSuggestion(null);
+
+ try {
+ const result = await AgentProfilesAPI.suggestProfile({
+ prompt: userPrompt,
+ });
+ setSuggestion(result);
+ } catch (err) {
+ logger.error('Failed to get profile suggestion', err);
+ const message = err instanceof Error ? err.message : 'Failed to get suggestion';
+ setError(message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAccept = () => {
+ const currentSuggestion = suggestion();
+ if (currentSuggestion) {
+ props.onSuggestionAccepted(currentSuggestion);
+ notificationStore.success(`Profile "${currentSuggestion.name}" ready to create`);
+ }
+ };
+
+ const handleUseExample = (example: string) => {
+ setPrompt(example);
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ AI Profile Suggestion
+
+
+ Describe what you need, and AI will draft a profile
+
+
+
+
+
+
+ {/* Content */}
+
+ {/* Prompt Input */}
+
+
+
+
+ {/* Example Prompts */}
+
+
+
Examples:
+
+
+ {(example) => (
+
+ )}
+
+
+
+
+
+ {/* Error Message */}
+
+
+
+
+ {/* Loading State */}
+
+
+
+ Generating suggestion...
+
+
+
+ {/* Suggestion Result */}
+
+ {(sugg) => (
+
+ {/* Draft Warning */}
+
+
+
+ This is a draft suggestion. Review the settings before creating the profile.
+
+
+
+ {/* Profile Preview */}
+
+ {/* Name & Description */}
+
+
{sugg().name}
+
{sugg().description}
+
+
+ {/* Config */}
+
+
Settings
+
+
+ {JSON.stringify(sugg().config, null, 2)}
+
+
+
+
+ {/* Rationale */}
+
0}>
+
+
Rationale
+
+
+ {(reason) => (
+ -
+
+ {reason}
+
+ )}
+
+
+
+
+
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+
+
+ {loading() ? 'Generating...' : 'Suggest Profile'}
+
+ }
+ >
+
+
+
+
+
+
+ );
+};
+
+export default SuggestProfileModal;
diff --git a/frontend-modern/src/components/Settings/UnifiedAgents.tsx b/frontend-modern/src/components/Settings/UnifiedAgents.tsx
index 7037e209d..1b1f53f28 100644
--- a/frontend-modern/src/components/Settings/UnifiedAgents.tsx
+++ b/frontend-modern/src/components/Settings/UnifiedAgents.tsx
@@ -3,6 +3,7 @@ import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format';
import { MonitoringAPI } from '@/api/monitoring';
+import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment } from '@/api/agentProfiles';
import { SecurityAPI } from '@/api/security';
import { notificationStore } from '@/stores/notifications';
import type { SecurityStatus } from '@/types/config';
@@ -23,6 +24,38 @@ const buildDefaultTokenName = () => {
};
type AgentPlatform = 'linux' | 'macos' | 'freebsd' | 'windows';
+type UnifiedAgentType = 'host' | 'docker' | 'kubernetes';
+type UnifiedAgentStatus = 'active' | 'removed';
+type ScopeCategory = 'default' | 'profile' | 'ai-managed' | 'na';
+
+type UnifiedAgentRow = {
+ rowKey: string;
+ id: string;
+ name: string;
+ hostname?: string;
+ displayName?: string;
+ types: UnifiedAgentType[];
+ status: UnifiedAgentStatus;
+ healthStatus?: string;
+ lastSeen?: number;
+ removedAt?: number;
+ version?: string;
+ isLegacy?: boolean;
+ linkedNodeId?: string;
+ commandsEnabled?: boolean;
+ agentId?: string;
+ scope: {
+ label: string;
+ detail?: string;
+ category: ScopeCategory;
+ };
+ searchText: string;
+ kubernetesInfo?: {
+ server?: string;
+ context?: string;
+ tokenName?: string;
+ };
+};
// Generate platform-specific commands with the appropriate Pulse URL
// Uses agentUrl from API (PULSE_PUBLIC_URL) if configured, otherwise falls back to window.location
@@ -127,8 +160,16 @@ export const UnifiedAgents: Component = () => {
const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806)
const [enableCommands, setEnableCommands] = createSignal(false); // Enable AI command execution (issue #903)
const [customAgentUrl, setCustomAgentUrl] = createSignal('');
+ const [profiles, setProfiles] = createSignal([]);
+ const [assignments, setAssignments] = createSignal([]);
// Track pending command config changes: hostId -> { desired value, timestamp }
const [pendingCommandConfig, setPendingCommandConfig] = createSignal>({});
+ const [pendingScopeUpdates, setPendingScopeUpdates] = createSignal>({});
+ const [expandedRowKey, setExpandedRowKey] = createSignal(null);
+ const [filterType, setFilterType] = createSignal<'all' | UnifiedAgentType>('all');
+ const [filterStatus, setFilterStatus] = createSignal<'all' | UnifiedAgentStatus>('all');
+ const [filterScope, setFilterScope] = createSignal<'all' | Exclude>('all');
+ const [filterSearch, setFilterSearch] = createSignal('');
createEffect(() => {
if (requiresToken()) {
@@ -174,6 +215,22 @@ export const UnifiedAgents: Component = () => {
}
};
fetchSecurityStatus();
+
+ const fetchAgentProfiles = async () => {
+ try {
+ const [profilesData, assignmentsData] = await Promise.all([
+ AgentProfilesAPI.listProfiles(),
+ AgentProfilesAPI.listAssignments(),
+ ]);
+ setProfiles(profilesData);
+ setAssignments(assignmentsData);
+ } catch (err) {
+ logger.debug('Failed to load agent profiles', err);
+ setProfiles([]);
+ setAssignments([]);
+ }
+ };
+ fetchAgentProfiles();
});
const requiresToken = () => {
@@ -288,6 +345,7 @@ export const UnifiedAgents: Component = () => {
isLegacy?: boolean;
linkedNodeId?: string;
commandsEnabled?: boolean;
+ agentId?: string;
}>();
// Process Host Agents (include linked ones with a badge)
@@ -296,6 +354,7 @@ export const UnifiedAgents: Component = () => {
const key = h.id;
unified.set(key, {
id: h.id,
+ agentId: h.id,
hostname: h.hostname || 'Unknown',
displayName: h.displayName,
types: ['host'],
@@ -317,12 +376,16 @@ export const UnifiedAgents: Component = () => {
if (!existing.types.includes('docker')) {
existing.types.push('docker');
}
+ if (!existing.agentId && d.agentId) {
+ existing.agentId = d.agentId;
+ }
// Update version/status if newer
if (!existing.version && d.agentVersion) existing.version = d.agentVersion;
if (d.isLegacy) existing.isLegacy = true;
} else {
unified.set(key, {
id: d.id,
+ agentId: d.agentId || d.id,
hostname: d.hostname || 'Unknown',
displayName: d.displayName,
types: ['docker'],
@@ -375,6 +438,88 @@ export const UnifiedAgents: Component = () => {
return Array.from(unified.values()).sort((a, b) => a.hostname.localeCompare(b.hostname));
});
+ const profileById = createMemo(() => {
+ const map = new Map();
+ for (const profile of profiles()) {
+ map.set(profile.id, profile);
+ }
+ return map;
+ });
+
+ const assignmentByAgent = createMemo(() => {
+ const map = new Map();
+ for (const assignment of assignments()) {
+ map.set(assignment.agent_id, assignment);
+ }
+ return map;
+ });
+
+ const getScopeInfo = (agentId: string | undefined) => {
+ if (!agentId) {
+ return { label: 'N/A', detail: '', category: 'na' as const };
+ }
+ const assignment = assignmentByAgent().get(agentId);
+ if (!assignment) {
+ return { label: 'Default', detail: 'Auto-detect', category: 'default' as const };
+ }
+ const profile = profileById().get(assignment.profile_id);
+ if (!profile) {
+ return { label: 'Profile assigned', detail: assignment.profile_id, category: 'profile' as const };
+ }
+ const name = profile.name || assignment.profile_id;
+ const isAIManaged =
+ profile.description?.toLowerCase().includes('pulse ai') ||
+ name.toLowerCase().startsWith('ai scope');
+ return isAIManaged
+ ? { label: 'AI-managed', detail: name, category: 'ai-managed' as const }
+ : { label: name, detail: 'Assigned profile', category: 'profile' as const };
+ };
+
+ const updateScopeAssignment = async (agentId: string, profileId: string | null, agentName: string) => {
+ if (!agentId) {
+ return;
+ }
+ if (pendingScopeUpdates()[agentId]) {
+ return;
+ }
+
+ setPendingScopeUpdates(prev => ({ ...prev, [agentId]: true }));
+ try {
+ if (profileId) {
+ await AgentProfilesAPI.assignProfile(agentId, profileId);
+ setAssignments(prev => {
+ const updatedAt = new Date().toISOString();
+ const next = prev.filter(a => a.agent_id !== agentId);
+ next.push({ agent_id: agentId, profile_id: profileId, updated_at: updatedAt });
+ return next;
+ });
+ notificationStore.success(`Scope updated for ${agentName}. Restart the agent to apply changes.`);
+ } else {
+ await AgentProfilesAPI.unassignProfile(agentId);
+ setAssignments(prev => prev.filter(a => a.agent_id !== agentId));
+ notificationStore.success(`Scope reset for ${agentName}. Restart the agent to apply changes.`);
+ }
+ } catch (err) {
+ logger.error('Failed to update agent scope', err);
+ notificationStore.error('Failed to update agent scope');
+ } finally {
+ setPendingScopeUpdates(prev => {
+ const next = { ...prev };
+ delete next[agentId];
+ return next;
+ });
+ }
+ };
+
+ const handleResetScope = async (agentId: string, agentName: string) => {
+ if (!confirm(`Reset scope for ${agentName}? This removes any assigned profile and reverts to auto-detect.`)) return;
+ await updateScopeAssignment(agentId, null, agentName);
+ };
+
+ const toggleAgentDetails = (rowKey: string) => {
+ setExpandedRowKey(prev => (prev === rowKey ? null : rowKey));
+ };
+
const legacyAgents = createMemo(() => allHosts().filter(h => h.isLegacy));
const hasLegacyAgents = createMemo(() => legacyAgents().length > 0);
@@ -382,7 +527,6 @@ export const UnifiedAgents: Component = () => {
const removed = state.removedDockerHosts || [];
return removed.sort((a, b) => b.removedAt - a.removedAt);
});
- const hasRemovedDockerHosts = createMemo(() => removedDockerHosts().length > 0);
const kubernetesClusters = createMemo(() => {
const clusters = state.kubernetesClusters || [];
@@ -393,7 +537,6 @@ export const UnifiedAgents: Component = () => {
const removed = state.removedKubernetesClusters || [];
return removed.sort((a, b) => b.removedAt - a.removedAt);
});
- const hasRemovedKubernetesClusters = createMemo(() => removedKubernetesClusters().length > 0);
// Host agents linked to PVE nodes (shown separately with unlink option)
const linkedHostAgents = createMemo(() => {
@@ -410,6 +553,138 @@ export const UnifiedAgents: Component = () => {
});
const hasLinkedAgents = createMemo(() => linkedHostAgents().length > 0);
+ const unifiedRows = createMemo(() => {
+ const rows: UnifiedAgentRow[] = [];
+
+ allHosts().forEach(agent => {
+ const resolvedAgentId = agent.agentId || agent.id;
+ const scopeInfo = getScopeInfo(resolvedAgentId);
+ const name = agent.displayName || agent.hostname;
+ const searchText = [name, agent.hostname, agent.id, resolvedAgentId]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase();
+
+ rows.push({
+ rowKey: `agent-${agent.id}`,
+ id: agent.id,
+ name,
+ hostname: agent.hostname,
+ displayName: agent.displayName,
+ types: agent.types,
+ status: 'active',
+ healthStatus: agent.status,
+ lastSeen: agent.lastSeen,
+ version: agent.version,
+ isLegacy: agent.isLegacy,
+ linkedNodeId: agent.linkedNodeId,
+ commandsEnabled: agent.commandsEnabled,
+ agentId: resolvedAgentId,
+ scope: scopeInfo,
+ searchText,
+ });
+ });
+
+ kubernetesClusters().forEach(cluster => {
+ const name = cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id;
+ rows.push({
+ rowKey: `k8s-${cluster.id}`,
+ id: cluster.id,
+ name,
+ types: ['kubernetes'],
+ status: 'active',
+ healthStatus: cluster.status,
+ lastSeen: cluster.lastSeen,
+ version: cluster.version || cluster.agentVersion,
+ agentId: cluster.agentId,
+ scope: getScopeInfo(undefined),
+ searchText: [name, cluster.name, cluster.displayName, cluster.id, cluster.server, cluster.context]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase(),
+ kubernetesInfo: {
+ server: cluster.server,
+ context: cluster.context,
+ tokenName: cluster.tokenName,
+ },
+ });
+ });
+
+ removedDockerHosts().forEach(host => {
+ const name = host.displayName || host.hostname || host.id;
+ rows.push({
+ rowKey: `removed-docker-${host.id}`,
+ id: host.id,
+ name,
+ hostname: host.hostname,
+ displayName: host.displayName,
+ types: ['docker'],
+ status: 'removed',
+ removedAt: host.removedAt,
+ scope: getScopeInfo(undefined),
+ searchText: [name, host.hostname, host.id].filter(Boolean).join(' ').toLowerCase(),
+ });
+ });
+
+ removedKubernetesClusters().forEach(cluster => {
+ const name = cluster.displayName || cluster.name || cluster.id;
+ rows.push({
+ rowKey: `removed-k8s-${cluster.id}`,
+ id: cluster.id,
+ name,
+ types: ['kubernetes'],
+ status: 'removed',
+ removedAt: cluster.removedAt,
+ scope: getScopeInfo(undefined),
+ searchText: [name, cluster.name, cluster.id].filter(Boolean).join(' ').toLowerCase(),
+ });
+ });
+
+ rows.sort((a, b) => {
+ if (a.status !== b.status) {
+ return a.status === 'active' ? -1 : 1;
+ }
+ return a.name.localeCompare(b.name);
+ });
+
+ return rows;
+ });
+
+ const filteredRows = createMemo(() => {
+ const query = filterSearch().trim().toLowerCase();
+ return unifiedRows().filter(row => {
+ if (filterType() !== 'all' && !row.types.includes(filterType())) {
+ return false;
+ }
+ if (filterStatus() !== 'all' && row.status !== filterStatus()) {
+ return false;
+ }
+ if (filterScope() !== 'all' && row.scope.category !== filterScope()) {
+ return false;
+ }
+ if (query && !row.searchText.includes(query)) {
+ return false;
+ }
+ return true;
+ });
+ });
+
+ const hasFilters = createMemo(() => {
+ return (
+ filterType() !== 'all' ||
+ filterStatus() !== 'all' ||
+ filterScope() !== 'all' ||
+ filterSearch().trim().length > 0
+ );
+ });
+
+ const resetFilters = () => {
+ setFilterType('all');
+ setFilterStatus('all');
+ setFilterScope('all');
+ setFilterSearch('');
+ };
+
const getUpgradeCommand = (_hostname: string) => {
const token = resolvedToken();
const url = customAgentUrl() || agentUrl();
@@ -894,19 +1169,17 @@ export const UnifiedAgents: Component = () => {
Managed Agents
- Overview of all agents currently reporting to Pulse.
+ All active and removed agents, including Kubernetes clusters.
- {/* Note about linked agents */}
- {linkedHostAgents().length} host agent{linkedHostAgents().length > 1 ? 's are' : ' is'} linked to Proxmox node{linkedHostAgents().length > 1 ? 's' : ''} and shown in the Dashboard with a +Agent badge.
- Manage linked agents →
+ {linkedHostAgents().length} host agent{linkedHostAgents().length > 1 ? 's are' : ' is'} linked to Proxmox node{linkedHostAgents().length > 1 ? 's' : ''} and flagged with a Linked badge.
@@ -917,351 +1190,449 @@ export const UnifiedAgents: Component = () => {
-
+
{legacyAgents().length} legacy agent{legacyAgents().length > 1 ? 's' : ''} detected
- Legacy agents (pulse-host-agent, pulse-docker-agent) are deprecated. Upgrade to the unified agent for auto-updates and combined host + Docker monitoring.
+ Legacy agents (pulse-host-agent, pulse-docker-agent) are deprecated. Expand a row to copy the upgrade command.
-
- Run this command on each legacy host to upgrade:
-
-
-
-
- {getUpgradeCommand('')}
-
-
-
-
-
-
- | Hostname |
- Type |
- Status |
- Version |
- AI Commands |
- Last Seen |
- Actions |
-
-
-
-
-
- No agents installed yet.
- |
-
- }>
- {(agent) => (
-
- |
- {agent.displayName || agent.hostname}
-
- ({agent.hostname})
-
-
-
- Linked
-
-
- |
-
-
-
- {(type) => (
-
- {type === 'host' ? 'Host' : 'Docker'}
-
- )}
-
-
- |
-
-
- {agent.status}
-
- |
-
- {agent.version || '—'}
-
-
- Legacy
-
-
- |
-
- —
- }
- >
- {(() => {
- // Use pending state if set, otherwise use agent-reported state
- const pending = pendingCommandConfig();
- const isPending = agent.id in pending;
- const effectiveEnabled = isPending ? pending[agent.id].enabled : agent.commandsEnabled;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setFilterSearch(event.currentTarget.value)}
+ placeholder="Search name, hostname, or ID"
+ class="w-full rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-800"
+ />
+
+
+
- return (
-
-
-
-
-
-
-
-
-
- );
- })()}
-
- |
-
- {agent.lastSeen ? formatRelativeTime(agent.lastSeen) : '—'}
- |
-
-
-
-
-
- |
-
- )}
-
-
-
-
-
-
-
-
-
Kubernetes Clusters
-
- Kubernetes clusters currently reporting to Pulse.
-
+
+ Showing {filteredRows().length} of {unifiedRows().length} records.
-
+
- | Cluster |
+ Name |
+ Type |
Status |
- Version |
+ Scope |
+ AI Commands |
Last Seen |
+ Version |
Actions |
-
-
- No Kubernetes clusters reporting yet.
+ |
+
+ No agents match the current filters.
+
|
}>
- {(cluster) => (
-
- |
- {cluster.customDisplayName || cluster.displayName || cluster.name || cluster.id}
- |
-
-
- {cluster.status}
-
- |
-
- {cluster.version || cluster.agentVersion || '—'}
- |
-
- {cluster.lastSeen ? formatRelativeTime(cluster.lastSeen) : '—'}
- |
-
-
- |
-
- )}
+ {(row) => {
+ const expanded = () => expandedRowKey() === row.rowKey;
+ const isActive = () => row.status === 'active';
+ const isRemoved = () => row.status === 'removed';
+ const isKubernetes = () => row.types.includes('kubernetes');
+ const resolvedAgentId = row.agentId || '';
+ const assignment = () => resolvedAgentId ? assignmentByAgent().get(resolvedAgentId) : undefined;
+ const isScopeUpdating = () => resolvedAgentId ? Boolean(pendingScopeUpdates()[resolvedAgentId]) : false;
+ const agentName = row.displayName || row.hostname || row.name;
+ const typeBadgeClass = (type: UnifiedAgentType) => {
+ if (type === 'host') {
+ return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
+ }
+ if (type === 'docker') {
+ return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
+ }
+ return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300';
+ };
+ const statusBadgeClass = () => {
+ if (isRemoved()) {
+ return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200';
+ }
+ if (connectedFromStatus(row.healthStatus)) {
+ return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
+ }
+ return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
+ };
+ const lastSeenLabel = () => {
+ if (isRemoved()) {
+ return row.removedAt ? `Removed ${formatRelativeTime(row.removedAt)}` : 'Removed';
+ }
+ return row.lastSeen ? formatRelativeTime(row.lastSeen) : '—';
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {row.name}
+
+
+
+ {row.hostname}
+
+
+
+
+
+ |
+
+
+
+ {(type) => (
+
+ {type === 'host' ? 'Host' : type === 'docker' ? 'Docker' : 'Kubernetes'}
+
+ )}
+
+
+ |
+
+
+ {isRemoved() ? 'Removed' : row.healthStatus || 'unknown'}
+
+ |
+
+ N/A
+ }>
+ 0} fallback={
+
+ {row.scope.label}
+
+ }>
+
+
+
+ Updating…
+
+
+
+
+ |
+
+ N/A
+ }>
+ {(() => {
+ const pending = pendingCommandConfig();
+ const isPending = row.id in pending;
+ const effectiveEnabled = isPending ? pending[row.id].enabled : Boolean(row.commandsEnabled);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ })()}
+
+ |
+
+ {lastSeenLabel()}
+ |
+
+ {row.version || '—'}
+ |
+
+ handleRemoveAgent(row.id, row.types.filter(type => type !== 'kubernetes') as ('host' | 'docker')[])}
+ class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
+ >
+ Remove
+
+ }>
+
+
+ }>
+ handleAllowKubernetesReenroll(row.id, row.name)}
+ class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
+ >
+ Allow re-enroll
+
+ }>
+
+
+
+ |
+
+
+
+
+
+
+
+
+ {(type) => (
+
+ {type === 'host' ? 'Host' : type === 'docker' ? 'Docker' : 'Kubernetes'}
+
+ )}
+
+
+
+ Legacy
+
+
+
+
+ Linked
+
+
+
+
+ ID: {row.id}
+
+
+
+ Agent ID: {row.agentId}
+
+
+
+
+ Linked node ID: {row.linkedNodeId}
+
+
+
+
+ Last seen {formatRelativeTime(row.lastSeen)} ({formatAbsoluteTime(row.lastSeen)})
+
+
+
+
+ Removed {formatRelativeTime(row.removedAt)} ({formatAbsoluteTime(row.removedAt)})
+
+
+
+
+
+ Server: {row.kubernetesInfo?.server}
+
+
+ Context: {row.kubernetesInfo?.context}
+
+
+ Token: {row.kubernetesInfo?.tokenName}
+
+
+
+
+
+ Scope profile: {row.scope.label}
+
+ {row.scope.detail}
+
+
+
+
+ Restart required to apply scope changes.
+
+
+
+
+
+
+
+ Utilities
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ >
+ );
+ }}
-
-
-
-
-
Removed Docker Hosts
-
- Docker hosts that were removed and are blocked from re-enrolling. Allow re-enrollment to let them report again.
-
-
-
-
-
-
-
- | Hostname |
- Host ID |
- Removed |
- Actions |
-
-
-
-
- {(host) => (
-
- |
- {host.displayName || host.hostname || 'Unknown'}
- |
-
- {host.id.slice(0, 8)}...
- |
-
- {formatRelativeTime(host.removedAt)}
- |
-
-
- |
-
- )}
-
-
-
-
-
-
-
-
-
-
-
Removed Kubernetes Clusters
-
- Kubernetes clusters that were removed and are blocked from re-enrolling. Allow re-enrollment to let them report again.
-
-
-
-
-
-
-
- | Cluster |
- Cluster ID |
- Removed |
- Actions |
-
-
-
-
- {(cluster) => (
-
- |
- {cluster.displayName || cluster.name || 'Unknown'}
- |
-
- {cluster.id.slice(0, 8)}...
- |
-
- {formatRelativeTime(cluster.removedAt)}
- |
-
-
- |
-
- )}
-
-
-
-
-
-
);
};
diff --git a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx
index 338bce2e1..74fc3a72c 100644
--- a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx
+++ b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx
@@ -1,11 +1,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, fireEvent, screen, waitFor, cleanup } from '@solidjs/testing-library';
+import { render, fireEvent, screen, waitFor, cleanup, within } from '@solidjs/testing-library';
import { createStore } from 'solid-js/store';
import { UnifiedAgents } from '../UnifiedAgents';
-import type { Host, DockerHost } from '@/types/api';
+import type { Host, DockerHost, KubernetesCluster, RemovedDockerHost, RemovedKubernetesCluster } from '@/types/api';
let mockWsStore: {
- state: { hosts: Host[]; dockerHosts: DockerHost[] };
+ state: {
+ hosts: Host[];
+ dockerHosts: DockerHost[];
+ kubernetesClusters?: KubernetesCluster[];
+ removedDockerHosts?: RemovedDockerHost[];
+ removedKubernetesClusters?: RemovedKubernetesCluster[];
+ };
connected: () => boolean;
reconnecting: () => boolean;
activeAlerts: unknown[];
@@ -20,6 +26,8 @@ const notificationErrorMock = vi.fn();
const notificationInfoMock = vi.fn();
const clipboardSpy = vi.fn();
const fetchMock = vi.fn();
+const listProfilesMock = vi.fn();
+const listAssignmentsMock = vi.fn();
vi.mock('@/App', () => ({
useWebSocket: () => mockWsStore,
@@ -40,6 +48,13 @@ vi.mock('@/api/security', () => ({
},
}));
+vi.mock('@/api/agentProfiles', () => ({
+ AgentProfilesAPI: {
+ listProfiles: (...args: unknown[]) => listProfilesMock(...args),
+ listAssignments: (...args: unknown[]) => listAssignmentsMock(...args),
+ },
+}));
+
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: (...args: unknown[]) => notificationSuccessMock(...args),
@@ -100,10 +115,37 @@ const createDockerHost = (overrides?: Partial): DockerHost => ({
...overrides,
});
-const setupComponent = (hosts: Host[] = [], dockerHosts: DockerHost[] = []) => {
+const createKubernetesCluster = (overrides?: Partial): KubernetesCluster => ({
+ id: 'cluster-1',
+ agentId: 'cluster-agent-1',
+ name: 'cluster-1',
+ displayName: 'Cluster One',
+ status: 'online',
+ lastSeen: Date.now(),
+ intervalSeconds: 30,
+ ...overrides,
+});
+
+const createRemovedDockerHost = (overrides?: Partial): RemovedDockerHost => ({
+ id: 'removed-docker-1',
+ hostname: 'old-docker.local',
+ removedAt: Date.now() - 60_000,
+ ...overrides,
+});
+
+const setupComponent = (
+ hosts: Host[] = [],
+ dockerHosts: DockerHost[] = [],
+ kubernetesClusters: KubernetesCluster[] = [],
+ removedDockerHosts: RemovedDockerHost[] = [],
+ removedKubernetesClusters: RemovedKubernetesCluster[] = [],
+) => {
const [state] = createStore({
hosts,
dockerHosts,
+ kubernetesClusters,
+ removedDockerHosts,
+ removedKubernetesClusters,
});
mockWsStore = {
@@ -124,6 +166,8 @@ beforeEach(() => {
notificationSuccessMock.mockReset();
notificationErrorMock.mockReset();
notificationInfoMock.mockReset();
+ listProfilesMock.mockReset();
+ listAssignmentsMock.mockReset();
clipboardSpy.mockReset().mockResolvedValue(undefined);
fetchMock.mockReset();
fetchMock.mockResolvedValue(
@@ -134,6 +178,9 @@ beforeEach(() => {
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator);
+
+ listProfilesMock.mockResolvedValue([]);
+ listAssignmentsMock.mockResolvedValue([]);
});
afterEach(() => {
@@ -298,8 +345,15 @@ describe('UnifiedAgents managed agents table', () => {
});
expect(screen.getByText('Test Server')).toBeInTheDocument();
- expect(screen.getByText('Host')).toBeInTheDocument();
expect(screen.getByText('online')).toBeInTheDocument();
+
+ const toggle = screen.getByRole('button', { name: /details for Test Server/i });
+ fireEvent.click(toggle);
+
+ const detailsRow = document.getElementById('agent-details-agent-host-1');
+ expect(detailsRow).not.toBeNull();
+ const details = within(detailsRow as HTMLElement);
+ expect(details.getByText('Host')).toBeInTheDocument();
});
it('displays docker hosts in the table', async () => {
@@ -314,7 +368,14 @@ describe('UnifiedAgents managed agents table', () => {
});
expect(screen.getByText('Docker Server')).toBeInTheDocument();
- expect(screen.getByText('Docker')).toBeInTheDocument();
+
+ const toggle = screen.getByRole('button', { name: /details for Docker Server/i });
+ fireEvent.click(toggle);
+
+ const detailsRow = document.getElementById('agent-details-agent-docker-host-1');
+ expect(detailsRow).not.toBeNull();
+ const details = within(detailsRow as HTMLElement);
+ expect(details.getByText('Docker')).toBeInTheDocument();
});
it('shows empty state when no agents are installed', async () => {
@@ -332,7 +393,49 @@ describe('UnifiedAgents managed agents table', () => {
await waitFor(() => {
expect(screen.getByText(/legacy agent.*detected/i)).toBeInTheDocument();
});
- expect(screen.getByText('Legacy')).toBeInTheDocument();
+
+ const toggle = screen.getByRole('button', { name: /details for Host One/i });
+ fireEvent.click(toggle);
+
+ const detailsRow = document.getElementById('agent-details-agent-host-1');
+ expect(detailsRow).not.toBeNull();
+ const details = within(detailsRow as HTMLElement);
+ expect(details.getByText('Legacy')).toBeInTheDocument();
+ });
+
+ it('filters removed agents with the status filter', async () => {
+ const host = createHost({ displayName: 'Active Host' });
+ const removedHost = createRemovedDockerHost();
+ setupComponent([host], [], [], [removedHost]);
+
+ await waitFor(() => {
+ expect(screen.getByText('Managed Agents')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Active Host')).toBeInTheDocument();
+ expect(screen.getByText('old-docker.local')).toBeInTheDocument();
+
+ const statusSelect = screen.getByLabelText('Status');
+ fireEvent.change(statusSelect, { target: { value: 'removed' } });
+
+ expect(screen.queryByText('Active Host')).not.toBeInTheDocument();
+ expect(screen.getByText('old-docker.local')).toBeInTheDocument();
+ });
+
+ it('shows Kubernetes clusters in the unified table', async () => {
+ const cluster = createKubernetesCluster({ displayName: 'K8s Alpha' });
+ setupComponent([], [], [cluster]);
+
+ await waitFor(() => {
+ expect(screen.getByText('Managed Agents')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('K8s Alpha')).toBeInTheDocument();
+
+ const typeSelect = screen.getByLabelText('Type');
+ fireEvent.change(typeSelect, { target: { value: 'kubernetes' } });
+
+ expect(screen.getByText('K8s Alpha')).toBeInTheDocument();
});
});
diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts
index b542d3fa0..8e52b6748 100644
--- a/frontend-modern/vite.config.ts
+++ b/frontend-modern/vite.config.ts
@@ -224,71 +224,6 @@ export default defineConfig({
target: backendUrl,
changeOrigin: true,
},
- // OpenCode API proxies - when OpenCode is embedded in iframe, its frontend
- // makes requests to window.location.origin. We proxy these to the backend
- // which forwards them to OpenCode's actual backend.
- // Note: /global is OpenCode's client-side route, not an API endpoint
- '/session': {
- target: backendUrl,
- changeOrigin: true,
- ws: true, // WebSocket support for session events
- },
- '/tui': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/config': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/file': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/find': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/instance': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/mcp': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/permission': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/project': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/provider': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/pty': {
- target: backendUrl,
- changeOrigin: true,
- ws: true, // WebSocket support for PTY
- },
- '/question': {
- target: backendUrl,
- changeOrigin: true,
- },
- '/experimental': {
- target: backendUrl,
- changeOrigin: true,
- },
- // OpenCode Web UI proxy - serves OpenCode's built-in interface
- '/opencode': {
- target: backendUrl,
- changeOrigin: true,
- // WebSocket support for OpenCode's real-time features
- ws: true,
- },
},
},
build: {
diff --git a/internal/ai/mcp/tools.go b/internal/ai/mcp/tools.go
index f9390011f..88afa0838 100644
--- a/internal/ai/mcp/tools.go
+++ b/internal/ai/mcp/tools.go
@@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net/http"
+ "sort"
+ "strconv"
"strings"
"time"
@@ -30,6 +32,22 @@ type AgentServer interface {
ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error)
}
+// AgentProfileManager manages centralized agent profiles and assignments.
+type AgentProfileManager interface {
+ ApplyAgentScope(ctx context.Context, agentID, agentLabel string, settings map[string]interface{}) (profileID, profileName string, created bool, err error)
+ AssignProfile(ctx context.Context, agentID, profileID string) (profileName string, err error)
+ GetAgentScope(ctx context.Context, agentID string) (*AgentScope, error)
+}
+
+// AgentScope summarizes profile scope applied to an agent.
+type AgentScope struct {
+ AgentID string
+ ProfileID string
+ ProfileName string
+ ProfileVersion int
+ Settings map[string]interface{}
+}
+
// MetadataUpdater updates resource metadata
type MetadataUpdater interface {
SetResourceURL(resourceType, resourceID, url string) error
@@ -168,6 +186,20 @@ type DiskHealthProvider interface {
GetHosts() []models.Host
}
+// ControlLevel represents the AI's permission level for infrastructure control
+type ControlLevel string
+
+const (
+ // ControlLevelReadOnly - AI can only query, no control tools available
+ ControlLevelReadOnly ControlLevel = "read_only"
+ // ControlLevelSuggest - AI suggests commands, user must copy/paste to execute
+ ControlLevelSuggest ControlLevel = "suggest"
+ // ControlLevelControlled - AI can execute with per-command approval
+ ControlLevelControlled ControlLevel = "controlled"
+ // ControlLevelAutonomous - AI executes without approval (requires Pro license)
+ ControlLevelAutonomous ControlLevel = "autonomous"
+)
+
// PulseToolExecutor implements ToolExecutor for Pulse-specific tools
type PulseToolExecutor struct {
stateProvider StateProvider
@@ -188,6 +220,12 @@ type PulseToolExecutor struct {
storageProvider StorageProvider
diskHealthProvider DiskHealthProvider
+ agentProfileManager AgentProfileManager
+
+ // Control settings
+ controlLevel ControlLevel
+ protectedGuests []string // VMIDs that AI cannot control
+
// Current execution context
targetType string
targetID string
@@ -257,6 +295,21 @@ func (e *PulseToolExecutor) SetDiskHealthProvider(provider DiskHealthProvider) {
e.diskHealthProvider = provider
}
+// SetAgentProfileManager sets the manager for centralized agent profiles.
+func (e *PulseToolExecutor) SetAgentProfileManager(manager AgentProfileManager) {
+ e.agentProfileManager = manager
+}
+
+// SetControlLevel sets the AI control permission level
+func (e *PulseToolExecutor) SetControlLevel(level ControlLevel) {
+ e.controlLevel = level
+}
+
+// SetProtectedGuests sets the list of VMIDs that AI cannot control
+func (e *PulseToolExecutor) SetProtectedGuests(vmids []string) {
+ e.protectedGuests = vmids
+}
+
// SetContext sets the current execution context
func (e *PulseToolExecutor) SetContext(targetType, targetID string, autonomous bool) {
e.targetType = targetType
@@ -266,27 +319,22 @@ func (e *PulseToolExecutor) SetContext(targetType, targetID string, autonomous b
// ListTools returns the list of available tools
func (e *PulseToolExecutor) ListTools() []Tool {
- return []Tool{
+ tools := []Tool{
{
- Name: "pulse_run_command",
- Description: "Execute a shell command on Pulse-managed infrastructure. By default runs on the current target, set run_on_host=true for host commands.",
+ Name: "pulse_get_agent_scope",
+ Description: "Get the current unified agent scope (profile assignment and settings).",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
- "command": {
+ "agent_id": {
Type: "string",
- Description: "The shell command to execute",
+ Description: "Unified agent ID (preferred if known)",
},
- "run_on_host": {
- Type: "boolean",
- Description: "If true, run on the host instead of inside the container/VM",
- },
- "target_host": {
+ "hostname": {
Type: "string",
- Description: "Optional hostname of the specific host/node to run the command on",
+ Description: "Hostname or display name to resolve the agent ID",
},
},
- Required: []string{"command"},
},
},
{
@@ -492,6 +540,108 @@ func (e *PulseToolExecutor) ListTools() []Tool {
},
},
}
+
+ // Add control tools if not in read_only mode
+ if e.controlLevel != ControlLevelReadOnly && e.controlLevel != "" {
+ controlTools := []Tool{
+ {
+ Name: "pulse_run_command",
+ Description: "Execute a shell command on Pulse-managed infrastructure. By default runs on the current target, set run_on_host=true for host commands.",
+ InputSchema: InputSchema{
+ Type: "object",
+ Properties: map[string]PropertySchema{
+ "command": {
+ Type: "string",
+ Description: "The shell command to execute",
+ },
+ "run_on_host": {
+ Type: "boolean",
+ Description: "If true, run on the host instead of inside the container/VM",
+ },
+ "target_host": {
+ Type: "string",
+ Description: "Optional hostname of the specific host/node to run the command on",
+ },
+ },
+ Required: []string{"command"},
+ },
+ },
+ {
+ Name: "pulse_control_guest",
+ Description: "Control Proxmox VMs and LXC containers. Actions: start, stop, shutdown (graceful), restart. Requires an agent on the Proxmox host.",
+ InputSchema: InputSchema{
+ Type: "object",
+ Properties: map[string]PropertySchema{
+ "guest_id": {
+ Type: "string",
+ Description: "The VMID (e.g., '101') or name of the VM/container to control",
+ },
+ "action": {
+ Type: "string",
+ Description: "Action to perform: start, stop, shutdown, restart",
+ Enum: []string{"start", "stop", "shutdown", "restart"},
+ },
+ "force": {
+ Type: "boolean",
+ Description: "If true, force stop without graceful shutdown (use with caution)",
+ },
+ },
+ Required: []string{"guest_id", "action"},
+ },
+ },
+ {
+ Name: "pulse_control_docker",
+ Description: "Control Docker containers. Actions: start, stop, restart. Requires an agent on the Docker host.",
+ InputSchema: InputSchema{
+ Type: "object",
+ Properties: map[string]PropertySchema{
+ "container": {
+ Type: "string",
+ Description: "The container name or ID to control",
+ },
+ "host": {
+ Type: "string",
+ Description: "The Docker host name (required if multiple hosts)",
+ },
+ "action": {
+ Type: "string",
+ Description: "Action to perform: start, stop, restart",
+ Enum: []string{"start", "stop", "restart"},
+ },
+ },
+ Required: []string{"container", "action"},
+ },
+ },
+ {
+ Name: "pulse_set_agent_scope",
+ Description: "Update a unified agent's scope via safe profile settings. Use this instead of running raw commands to enable/disable modules like Docker, Kubernetes, or Proxmox.",
+ InputSchema: InputSchema{
+ Type: "object",
+ Properties: map[string]PropertySchema{
+ "agent_id": {
+ Type: "string",
+ Description: "Unified agent ID (preferred if known)",
+ },
+ "hostname": {
+ Type: "string",
+ Description: "Hostname or display name to resolve the agent ID",
+ },
+ "profile_id": {
+ Type: "string",
+ Description: "Assign an existing profile ID (optional; omit to use settings)",
+ },
+ "settings": {
+ Type: "object",
+ Description: "Profile settings (e.g., enable_host, enable_docker, enable_kubernetes, enable_proxmox, proxmox_type, docker_runtime, disable_auto_update, disable_docker_update_checks, kube_include_all_pods, kube_include_all_deployments, log_level, interval, report_ip, disable_ceph)",
+ },
+ },
+ },
+ },
+ }
+ tools = append(tools, controlTools...)
+ }
+
+ return tools
}
// ExecuteTool executes a tool and returns the result
@@ -508,6 +658,8 @@ func (e *PulseToolExecutor) ExecuteTool(ctx context.Context, name string, args m
return e.executeFetchURL(ctx, args)
case "pulse_get_infrastructure_state":
return e.executeGetInfrastructureState(ctx)
+ case "pulse_get_agent_scope":
+ return e.executeGetAgentScope(ctx, args)
case "pulse_set_resource_url":
return e.executeSetResourceURL(ctx, args)
case "pulse_resolve_finding":
@@ -532,6 +684,12 @@ func (e *PulseToolExecutor) ExecuteTool(ctx context.Context, name string, args m
return e.executeGetResourceDetails(ctx, args)
case "pulse_get_disk_health":
return e.executeGetDiskHealth(ctx, args)
+ case "pulse_control_guest":
+ return e.executeControlGuest(ctx, args)
+ case "pulse_control_docker":
+ return e.executeControlDocker(ctx, args)
+ case "pulse_set_agent_scope":
+ return e.executeSetAgentScope(ctx, args)
default:
return NewErrorResult(fmt.Errorf("unknown tool: %s", name)), nil
}
@@ -546,15 +704,28 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
return NewErrorResult(fmt.Errorf("command is required")), nil
}
+ if e.controlLevel == ControlLevelReadOnly || e.controlLevel == "" {
+ return NewTextResult("Control tools are disabled. Enable them in Settings > AI > Control Level."), nil
+ }
+
// Check security policy
+ decision := agentexec.PolicyAllow
if e.policy != nil {
- decision := e.policy.Evaluate(command)
+ decision = e.policy.Evaluate(command)
if decision == agentexec.PolicyBlock {
return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil
}
- if decision == agentexec.PolicyRequireApproval && !e.isAutonomous {
- return NewTextResult(formatApprovalNeeded(command, "Security policy requires approval")), nil
- }
+ }
+
+ if e.controlLevel == ControlLevelSuggest {
+ return NewTextResult(formatCommandSuggestion(command, runOnHost, targetHost)), nil
+ }
+
+ if e.controlLevel == ControlLevelControlled {
+ return NewTextResult(formatApprovalNeeded(command, "Control level requires approval")), nil
+ }
+ if decision == agentexec.PolicyRequireApproval && !e.isAutonomous {
+ return NewTextResult(formatApprovalNeeded(command, "Security policy requires approval")), nil
}
// Execute via agent server
@@ -595,6 +766,297 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
return NewTextResult(output), nil
}
+func (e *PulseToolExecutor) executeSetAgentScope(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
+ if e.agentProfileManager == nil {
+ return NewTextResult("Agent scope management is not available."), nil
+ }
+ if e.controlLevel == ControlLevelReadOnly || e.controlLevel == "" {
+ return NewTextResult("Agent scope tools are disabled. Enable them in Settings > AI > Control Level."), nil
+ }
+
+ agentID, _ := args["agent_id"].(string)
+ hostname, _ := args["hostname"].(string)
+ profileID, _ := args["profile_id"].(string)
+
+ agentID = strings.TrimSpace(agentID)
+ hostname = strings.TrimSpace(hostname)
+ profileID = strings.TrimSpace(profileID)
+
+ settings := map[string]interface{}{}
+ if rawSettings, ok := args["settings"].(map[string]interface{}); ok {
+ for key, value := range rawSettings {
+ if value != nil {
+ settings[key] = value
+ }
+ }
+ }
+
+ if agentID == "" && hostname == "" {
+ return NewErrorResult(fmt.Errorf("agent_id or hostname is required")), nil
+ }
+
+ agentLabel := agentID
+ if agentID == "" {
+ if e.stateProvider == nil {
+ return NewErrorResult(fmt.Errorf("state provider not available to resolve hostname")), nil
+ }
+ resolvedID, resolvedLabel := resolveAgentFromHostname(e.stateProvider.GetState(), hostname)
+ if resolvedID == "" {
+ return NewTextResult(fmt.Sprintf("No agent found for hostname '%s'.", hostname)), nil
+ }
+ agentID = resolvedID
+ agentLabel = resolvedLabel
+ } else if e.stateProvider != nil {
+ if resolvedLabel := resolveAgentLabel(e.stateProvider.GetState(), agentID); resolvedLabel != "" {
+ agentLabel = resolvedLabel
+ }
+ }
+
+ if profileID != "" && len(settings) > 0 {
+ return NewErrorResult(fmt.Errorf("use either profile_id or settings, not both")), nil
+ }
+
+ if e.controlLevel == ControlLevelSuggest {
+ if profileID != "" {
+ return NewTextResult(fmt.Sprintf("Suggestion: assign profile %s to agent %s.", profileID, agentLabel)), nil
+ }
+ if len(settings) == 0 {
+ return NewErrorResult(fmt.Errorf("settings are required when profile_id is not provided")), nil
+ }
+ return NewTextResult(fmt.Sprintf("Suggestion: apply agent scope to %s with settings: %s", agentLabel, formatSettingsSummary(settings))), nil
+ }
+
+ if profileID != "" {
+ profileName, err := e.agentProfileManager.AssignProfile(ctx, agentID, profileID)
+ if err != nil {
+ return NewErrorResult(err), nil
+ }
+ return NewTextResult(fmt.Sprintf("Assigned profile '%s' (%s) to agent %s. Restart the agent to apply changes.", profileName, profileID, agentLabel)), nil
+ }
+
+ if len(settings) == 0 {
+ return NewErrorResult(fmt.Errorf("settings are required when profile_id is not provided")), nil
+ }
+
+ profileID, profileName, created, err := e.agentProfileManager.ApplyAgentScope(ctx, agentID, agentLabel, settings)
+ if err != nil {
+ return NewErrorResult(err), nil
+ }
+
+ action := "Updated"
+ if created {
+ action = "Created"
+ }
+ return NewTextResult(fmt.Sprintf("%s profile '%s' (%s) and assigned to agent %s. Restart the agent to apply changes. Settings: %s", action, profileName, profileID, agentLabel, formatSettingsSummary(settings))), nil
+}
+
+func (e *PulseToolExecutor) executeGetAgentScope(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
+ agentID, _ := args["agent_id"].(string)
+ hostname, _ := args["hostname"].(string)
+ agentID = strings.TrimSpace(agentID)
+ hostname = strings.TrimSpace(hostname)
+
+ if agentID == "" && hostname == "" {
+ return NewErrorResult(fmt.Errorf("agent_id or hostname is required")), nil
+ }
+
+ agentLabel := agentID
+ if agentID == "" {
+ if e.stateProvider == nil {
+ return NewErrorResult(fmt.Errorf("state provider not available to resolve hostname")), nil
+ }
+ resolvedID, resolvedLabel := resolveAgentFromHostname(e.stateProvider.GetState(), hostname)
+ if resolvedID == "" {
+ return NewTextResult(fmt.Sprintf("No agent found for hostname '%s'.", hostname)), nil
+ }
+ agentID = resolvedID
+ agentLabel = resolvedLabel
+ } else if e.stateProvider != nil {
+ if resolvedLabel := resolveAgentLabel(e.stateProvider.GetState(), agentID); resolvedLabel != "" {
+ agentLabel = resolvedLabel
+ }
+ }
+
+ var scope *AgentScope
+ if e.agentProfileManager != nil {
+ var err error
+ scope, err = e.agentProfileManager.GetAgentScope(ctx, agentID)
+ if err != nil {
+ return NewTextResult(fmt.Sprintf("Failed to load agent scope for %s: %v", agentLabel, err)), nil
+ }
+ }
+
+ var observed []string
+ var commandsEnabled *bool
+ if e.stateProvider != nil {
+ observed, commandsEnabled = detectAgentModules(e.stateProvider.GetState(), agentID)
+ }
+
+ var summary strings.Builder
+ summary.WriteString(fmt.Sprintf("Agent: %s\n", agentLabel))
+ summary.WriteString(fmt.Sprintf("Agent ID: %s\n", agentID))
+
+ if scope == nil {
+ summary.WriteString("Assigned profile: none\n")
+ } else {
+ summary.WriteString(fmt.Sprintf("Assigned profile: %s (%s)\n", scope.ProfileName, scope.ProfileID))
+ if scope.ProfileVersion > 0 {
+ summary.WriteString(fmt.Sprintf("Profile version: %d\n", scope.ProfileVersion))
+ }
+ }
+
+ if len(observed) > 0 {
+ summary.WriteString(fmt.Sprintf("Observed modules: %s\n", strings.Join(observed, ", ")))
+ }
+ if commandsEnabled != nil {
+ if *commandsEnabled {
+ summary.WriteString("AI commands: enabled\n")
+ } else {
+ summary.WriteString("AI commands: disabled\n")
+ }
+ }
+
+ if scope != nil && len(scope.Settings) > 0 {
+ summary.WriteString("Profile settings:\n")
+ for _, line := range formatSettingsLines(scope.Settings) {
+ summary.WriteString(line)
+ }
+ } else {
+ summary.WriteString("Profile settings: none\n")
+ }
+
+ summary.WriteString("Note: profile changes apply after the agent restarts.")
+
+ return NewTextResult(summary.String()), nil
+}
+
+func resolveAgentFromHostname(state models.StateSnapshot, hostname string) (string, string) {
+ needle := strings.TrimSpace(hostname)
+ if needle == "" {
+ return "", ""
+ }
+ for _, host := range state.Hosts {
+ if strings.EqualFold(host.Hostname, needle) || strings.EqualFold(host.DisplayName, needle) || strings.EqualFold(host.ID, needle) {
+ label := firstNonEmpty(host.DisplayName, host.Hostname, host.ID)
+ return host.ID, label
+ }
+ }
+ for _, host := range state.DockerHosts {
+ if strings.EqualFold(host.Hostname, needle) || strings.EqualFold(host.DisplayName, needle) || strings.EqualFold(host.CustomDisplayName, needle) || strings.EqualFold(host.ID, needle) {
+ label := firstNonEmpty(host.CustomDisplayName, host.DisplayName, host.Hostname, host.ID)
+ agentID := strings.TrimSpace(host.AgentID)
+ if agentID == "" {
+ agentID = host.ID
+ }
+ return agentID, label
+ }
+ }
+ return "", ""
+}
+
+func resolveAgentLabel(state models.StateSnapshot, agentID string) string {
+ needle := strings.TrimSpace(agentID)
+ if needle == "" {
+ return ""
+ }
+ for _, host := range state.Hosts {
+ if strings.EqualFold(host.ID, needle) {
+ return firstNonEmpty(host.DisplayName, host.Hostname, host.ID)
+ }
+ }
+ for _, host := range state.DockerHosts {
+ if strings.EqualFold(host.AgentID, needle) || strings.EqualFold(host.ID, needle) {
+ return firstNonEmpty(host.CustomDisplayName, host.DisplayName, host.Hostname, host.ID)
+ }
+ }
+ return ""
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func formatSettingsSummary(settings map[string]interface{}) string {
+ if len(settings) == 0 {
+ return "none"
+ }
+ keys := make([]string, 0, len(settings))
+ for key := range settings {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ parts := make([]string, 0, len(keys))
+ for _, key := range keys {
+ parts = append(parts, fmt.Sprintf("%s=%v", key, settings[key]))
+ }
+ return strings.Join(parts, ", ")
+}
+
+func formatSettingsLines(settings map[string]interface{}) []string {
+ if len(settings) == 0 {
+ return []string{" - none\n"}
+ }
+ keys := make([]string, 0, len(settings))
+ for key := range settings {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ lines := make([]string, 0, len(keys))
+ for _, key := range keys {
+ lines = append(lines, fmt.Sprintf(" - %s: %v\n", key, settings[key]))
+ }
+ return lines
+}
+
+func detectAgentModules(state models.StateSnapshot, agentID string) ([]string, *bool) {
+ agentID = strings.TrimSpace(agentID)
+ if agentID == "" {
+ return nil, nil
+ }
+
+ var modules []string
+ var commandsEnabled *bool
+
+ for _, host := range state.Hosts {
+ if strings.EqualFold(host.ID, agentID) {
+ modules = append(modules, "host")
+ val := host.CommandsEnabled
+ commandsEnabled = &val
+ if host.LinkedNodeID != "" {
+ modules = append(modules, "proxmox")
+ }
+ break
+ }
+ }
+
+ for _, dockerHost := range state.DockerHosts {
+ if strings.EqualFold(dockerHost.AgentID, agentID) || strings.EqualFold(dockerHost.ID, agentID) {
+ modules = append(modules, "docker")
+ break
+ }
+ }
+
+ for _, cluster := range state.KubernetesClusters {
+ if strings.EqualFold(cluster.AgentID, agentID) {
+ modules = append(modules, "kubernetes")
+ break
+ }
+ }
+
+ if len(modules) == 0 {
+ return nil, commandsEnabled
+ }
+
+ sort.Strings(modules)
+ return modules, commandsEnabled
+}
+
func (e *PulseToolExecutor) executeFetchURL(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
url, _ := args["url"].(string)
if url == "" {
@@ -774,6 +1236,17 @@ func formatPolicyBlocked(command, reason string) string {
return "POLICY_BLOCKED: " + string(b)
}
+func formatCommandSuggestion(command string, runOnHost bool, targetHost string) string {
+ target := "current target"
+ if runOnHost {
+ target = "host"
+ }
+ if strings.TrimSpace(targetHost) != "" {
+ target = fmt.Sprintf("host %s", targetHost)
+ }
+ return fmt.Sprintf("Suggested command for %s:\n%s", target, command)
+}
+
// Patrol context tool implementations
func (e *PulseToolExecutor) executeGetMetricsHistory(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
@@ -1423,3 +1896,383 @@ func (e *PulseToolExecutor) executeGetDiskHealth(_ context.Context, _ map[string
return NewTextResult(result.String()), nil
}
+
+// Control tool implementations
+
+// GuestInfo represents resolved guest information
+type GuestInfo struct {
+ VMID int
+ Name string
+ Node string
+ Type string // "vm" or "lxc"
+ Status string
+ Instance string
+}
+
+func (e *PulseToolExecutor) executeControlGuest(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
+ guestID, _ := args["guest_id"].(string)
+ action, _ := args["action"].(string)
+ force, _ := args["force"].(bool)
+
+ if guestID == "" {
+ return NewErrorResult(fmt.Errorf("guest_id is required")), nil
+ }
+ if action == "" {
+ return NewErrorResult(fmt.Errorf("action is required")), nil
+ }
+
+ // Validate action
+ validActions := map[string]bool{"start": true, "stop": true, "shutdown": true, "restart": true}
+ if !validActions[action] {
+ return NewErrorResult(fmt.Errorf("invalid action: %s. Use start, stop, shutdown, or restart", action)), nil
+ }
+
+ // Check control level
+ if e.controlLevel == ControlLevelReadOnly || e.controlLevel == "" {
+ return NewTextResult("Control tools are disabled. Enable them in Settings > AI > Control Level."), nil
+ }
+
+ // Resolve guest to find VMID, node, and type
+ guest, err := e.resolveGuest(guestID)
+ if err != nil {
+ return NewTextResult(fmt.Sprintf("Could not find guest '%s': %v", guestID, err)), nil
+ }
+
+ // Check if guest is protected
+ vmidStr := fmt.Sprintf("%d", guest.VMID)
+ for _, protected := range e.protectedGuests {
+ if protected == vmidStr || protected == guest.Name {
+ return NewTextResult(fmt.Sprintf("Guest %s (VMID %d) is protected and cannot be controlled by AI.", guest.Name, guest.VMID)), nil
+ }
+ }
+
+ // Build the command based on guest type and action
+ var command string
+ cmdTool := "pct" // LXC containers
+ if guest.Type == "vm" {
+ cmdTool = "qm"
+ }
+
+ switch action {
+ case "start":
+ command = fmt.Sprintf("%s start %d", cmdTool, guest.VMID)
+ case "stop":
+ command = fmt.Sprintf("%s stop %d", cmdTool, guest.VMID)
+ case "shutdown":
+ command = fmt.Sprintf("%s shutdown %d", cmdTool, guest.VMID)
+ case "restart":
+ // Restart is shutdown + start, but we'll use reboot for simplicity
+ command = fmt.Sprintf("%s reboot %d", cmdTool, guest.VMID)
+ }
+
+ // Add force flag if requested (only for stop)
+ if force && action == "stop" {
+ command = fmt.Sprintf("%s stop %d --skiplock", cmdTool, guest.VMID)
+ }
+
+ // Check security policy
+ if e.policy != nil {
+ decision := e.policy.Evaluate(command)
+ if decision == agentexec.PolicyBlock {
+ return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil
+ }
+ // For control level "controlled", always require approval
+ if e.controlLevel == ControlLevelControlled || (decision == agentexec.PolicyRequireApproval && !e.isAutonomous) {
+ return NewTextResult(formatControlApprovalNeeded(guest.Name, guest.VMID, action, command)), nil
+ }
+ }
+
+ // For "suggest" mode, just return the command suggestion
+ if e.controlLevel == ControlLevelSuggest {
+ return NewTextResult(formatControlSuggestion(guest.Name, guest.VMID, action, command, guest.Node)), nil
+ }
+
+ // Execute the command via agent
+ if e.agentServer == nil {
+ return NewErrorResult(fmt.Errorf("no agent server available")), nil
+ }
+
+ // Find agent for the node that owns this guest
+ agentID := e.findAgentForNode(guest.Node)
+ if agentID == "" {
+ return NewTextResult(fmt.Sprintf("No agent available on node '%s'. Install Pulse Unified Agent on the Proxmox host to enable control.", guest.Node)), nil
+ }
+
+ // Execute command
+ result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
+ Command: command,
+ TargetType: "host",
+ TargetID: "",
+ })
+ if err != nil {
+ return NewErrorResult(err), nil
+ }
+
+ // Format result
+ output := result.Stdout
+ if result.Stderr != "" {
+ output += "\n" + result.Stderr
+ }
+
+ if result.ExitCode == 0 {
+ return NewTextResult(fmt.Sprintf("Successfully executed '%s' on %s (VMID %d).\n%s", action, guest.Name, guest.VMID, output)), nil
+ }
+
+ return NewTextResult(fmt.Sprintf("Command failed (exit code %d):\n%s", result.ExitCode, output)), nil
+}
+
+func (e *PulseToolExecutor) executeControlDocker(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
+ containerName, _ := args["container"].(string)
+ hostName, _ := args["host"].(string)
+ action, _ := args["action"].(string)
+
+ if containerName == "" {
+ return NewErrorResult(fmt.Errorf("container name is required")), nil
+ }
+ if action == "" {
+ return NewErrorResult(fmt.Errorf("action is required")), nil
+ }
+
+ // Validate action
+ validActions := map[string]bool{"start": true, "stop": true, "restart": true}
+ if !validActions[action] {
+ return NewErrorResult(fmt.Errorf("invalid action: %s. Use start, stop, or restart", action)), nil
+ }
+
+ // Check control level
+ if e.controlLevel == ControlLevelReadOnly || e.controlLevel == "" {
+ return NewTextResult("Control tools are disabled. Enable them in Settings > AI > Control Level."), nil
+ }
+
+ // Find the Docker container and its host
+ container, dockerHost, err := e.resolveDockerContainer(containerName, hostName)
+ if err != nil {
+ return NewTextResult(fmt.Sprintf("Could not find Docker container '%s': %v", containerName, err)), nil
+ }
+
+ // Build the command
+ command := fmt.Sprintf("docker %s %s", action, container.Name)
+
+ // Check security policy
+ if e.policy != nil {
+ decision := e.policy.Evaluate(command)
+ if decision == agentexec.PolicyBlock {
+ return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil
+ }
+ if e.controlLevel == ControlLevelControlled || (decision == agentexec.PolicyRequireApproval && !e.isAutonomous) {
+ return NewTextResult(formatDockerApprovalNeeded(container.Name, dockerHost.Hostname, action, command)), nil
+ }
+ }
+
+ // For "suggest" mode, just return the command suggestion
+ if e.controlLevel == ControlLevelSuggest {
+ return NewTextResult(formatDockerSuggestion(container.Name, dockerHost.Hostname, action, command)), nil
+ }
+
+ // Execute the command via agent
+ if e.agentServer == nil {
+ return NewErrorResult(fmt.Errorf("no agent server available")), nil
+ }
+
+ // Find agent for this Docker host
+ agentID := e.findAgentForDockerHost(dockerHost)
+ if agentID == "" {
+ return NewTextResult(fmt.Sprintf("No agent available on Docker host '%s'. Install Pulse Unified Agent on the host to enable control.", dockerHost.Hostname)), nil
+ }
+
+ // Execute command
+ result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
+ Command: command,
+ TargetType: "host",
+ TargetID: "",
+ })
+ if err != nil {
+ return NewErrorResult(err), nil
+ }
+
+ // Format result
+ output := result.Stdout
+ if result.Stderr != "" {
+ output += "\n" + result.Stderr
+ }
+
+ if result.ExitCode == 0 {
+ return NewTextResult(fmt.Sprintf("Successfully executed 'docker %s' on container '%s' (host: %s).\n%s", action, container.Name, dockerHost.Hostname, output)), nil
+ }
+
+ return NewTextResult(fmt.Sprintf("Command failed (exit code %d):\n%s", result.ExitCode, output)), nil
+}
+
+// resolveGuest finds a guest (VM or container) by VMID or name
+func (e *PulseToolExecutor) resolveGuest(guestID string) (*GuestInfo, error) {
+ if e.stateProvider == nil {
+ return nil, fmt.Errorf("state provider not available")
+ }
+
+ state := e.stateProvider.GetState()
+
+ // Try to parse as VMID
+ vmid, err := strconv.Atoi(guestID)
+
+ // Search VMs
+ for _, vm := range state.VMs {
+ if (err == nil && vm.VMID == vmid) || vm.Name == guestID || vm.ID == guestID {
+ return &GuestInfo{
+ VMID: vm.VMID,
+ Name: vm.Name,
+ Node: vm.Node,
+ Type: "vm",
+ Status: vm.Status,
+ Instance: vm.Instance,
+ }, nil
+ }
+ }
+
+ // Search containers
+ for _, ct := range state.Containers {
+ if (err == nil && ct.VMID == vmid) || ct.Name == guestID || ct.ID == guestID {
+ return &GuestInfo{
+ VMID: ct.VMID,
+ Name: ct.Name,
+ Node: ct.Node,
+ Type: "lxc",
+ Status: ct.Status,
+ Instance: ct.Instance,
+ }, nil
+ }
+ }
+
+ return nil, fmt.Errorf("no VM or container found with ID or name '%s'", guestID)
+}
+
+// resolveDockerContainer finds a Docker container by name or ID
+func (e *PulseToolExecutor) resolveDockerContainer(containerName, hostName string) (*models.DockerContainer, *models.DockerHost, error) {
+ if e.stateProvider == nil {
+ return nil, nil, fmt.Errorf("state provider not available")
+ }
+
+ state := e.stateProvider.GetState()
+
+ for _, host := range state.DockerHosts {
+ // If host name specified, only search that host
+ if hostName != "" && host.Hostname != hostName && host.DisplayName != hostName {
+ continue
+ }
+
+ for i, container := range host.Containers {
+ if container.Name == containerName ||
+ container.ID == containerName ||
+ strings.HasPrefix(container.ID, containerName) {
+ return &host.Containers[i], &host, nil
+ }
+ }
+ }
+
+ if hostName != "" {
+ return nil, nil, fmt.Errorf("container '%s' not found on host '%s'", containerName, hostName)
+ }
+ return nil, nil, fmt.Errorf("container '%s' not found on any Docker host", containerName)
+}
+
+// findAgentForNode finds an agent connected to a specific Proxmox node
+func (e *PulseToolExecutor) findAgentForNode(nodeName string) string {
+ if e.agentServer == nil {
+ return ""
+ }
+
+ agents := e.agentServer.GetConnectedAgents()
+ for _, agent := range agents {
+ // Check if agent hostname matches node name (common setup)
+ if agent.Hostname == nodeName {
+ return agent.AgentID
+ }
+ // Also check if agent has a linked node
+ // Note: This requires the Host model to have LinkedNodeID populated
+ }
+
+ // If no exact match, check hosts for linked agents
+ if e.stateProvider != nil {
+ state := e.stateProvider.GetState()
+ for _, host := range state.Hosts {
+ if host.LinkedNodeID != "" {
+ // Check if this host's linked node matches
+ for _, node := range state.Nodes {
+ if node.ID == host.LinkedNodeID && node.Name == nodeName {
+ // Find agent for this host
+ for _, agent := range agents {
+ if agent.Hostname == host.Hostname || agent.AgentID == host.ID {
+ return agent.AgentID
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return ""
+}
+
+// findAgentForDockerHost finds an agent connected to a Docker host
+func (e *PulseToolExecutor) findAgentForDockerHost(dockerHost *models.DockerHost) string {
+ if e.agentServer == nil {
+ return ""
+ }
+
+ agents := e.agentServer.GetConnectedAgents()
+ for _, agent := range agents {
+ if agent.Hostname == dockerHost.Hostname {
+ return agent.AgentID
+ }
+ }
+
+ return ""
+}
+
+// formatControlApprovalNeeded formats a response for guest control needing approval
+func formatControlApprovalNeeded(name string, vmid int, action, command string) string {
+ payload := map[string]interface{}{
+ "type": "control_approval_required",
+ "guest_name": name,
+ "guest_vmid": vmid,
+ "action": action,
+ "command": command,
+ "how_to_approve": "This action requires approval. Ask the user to confirm they want to proceed.",
+ "do_not_retry": true,
+ }
+ b, _ := json.Marshal(payload)
+ return "APPROVAL_REQUIRED: " + string(b)
+}
+
+// formatDockerApprovalNeeded formats a response for Docker control needing approval
+func formatDockerApprovalNeeded(name, host, action, command string) string {
+ payload := map[string]interface{}{
+ "type": "control_approval_required",
+ "container_name": name,
+ "docker_host": host,
+ "action": action,
+ "command": command,
+ "how_to_approve": "This action requires approval. Ask the user to confirm they want to proceed.",
+ "do_not_retry": true,
+ }
+ b, _ := json.Marshal(payload)
+ return "APPROVAL_REQUIRED: " + string(b)
+}
+
+// formatControlSuggestion formats a command suggestion for "suggest" mode
+func formatControlSuggestion(name string, vmid int, action, command, node string) string {
+ return fmt.Sprintf(`To %s %s (VMID %d), run this command on node %s:
+
+%s
+
+Copy and paste this command to execute it manually.`, action, name, vmid, node, command)
+}
+
+// formatDockerSuggestion formats a Docker command suggestion for "suggest" mode
+func formatDockerSuggestion(name, host, action, command string) string {
+ return fmt.Sprintf(`To %s container '%s' on host %s, run:
+
+%s
+
+Copy and paste this command to execute it manually.`, action, name, host, command)
+}
diff --git a/internal/ai/opencode/client.go b/internal/ai/opencode/client.go
index 00c1765d1..5b06e1f7e 100644
--- a/internal/ai/opencode/client.go
+++ b/internal/ai/opencode/client.go
@@ -266,10 +266,36 @@ func (c *Client) Prompt(ctx context.Context, req PromptRequest) (*PromptResponse
return nil, fmt.Errorf("prompt failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
+ // Parse the OpenCode response format which has info and parts fields
+ var rawResponse struct {
+ Info struct {
+ ID string `json:"id"`
+ SessionID string `json:"sessionID"`
+ Role string `json:"role"`
+ } `json:"info"`
+ Parts []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"parts"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&rawResponse); err != nil {
+ return nil, err
+ }
+
+ // Extract text content from parts
+ var contentParts []string
+ for _, part := range rawResponse.Parts {
+ if part.Type == "text" && part.Text != "" {
+ contentParts = append(contentParts, part.Text)
+ }
+ }
+
var result PromptResponse
result.SessionID = sessionID
- if err := json.NewDecoder(resp.Body).Decode(&result.Message); err != nil {
- return nil, err
+ result.Message = Message{
+ ID: rawResponse.Info.ID,
+ Role: rawResponse.Info.Role,
+ Content: strings.Join(contentParts, ""),
}
return &result, nil
diff --git a/internal/ai/opencode/service.go b/internal/ai/opencode/service.go
index c845eb306..567cd412a 100644
--- a/internal/ai/opencode/service.go
+++ b/internal/ai/opencode/service.go
@@ -86,6 +86,13 @@ func NewService(cfg Config) *Service {
executor := mcp.NewPulseToolExecutor(stateProvider, policy, agentServer)
+ // Set control level from config
+ if cfg.AIConfig != nil {
+ controlLevel := cfg.AIConfig.GetControlLevel()
+ executor.SetControlLevel(mcp.ControlLevel(controlLevel))
+ executor.SetProtectedGuests(cfg.AIConfig.GetProtectedGuests())
+ }
+
return &Service{
cfg: cfg.AIConfig,
executor: executor,
@@ -258,6 +265,13 @@ func (s *Service) Restart(ctx context.Context, newCfg *config.AIConfig) error {
s.sidecar.UpdateModel(model)
log.Info().Str("model", model).Msg("Updating OpenCode model")
}
+
+ // Update control settings on the executor (no restart needed)
+ if s.executor != nil {
+ s.executor.SetControlLevel(mcp.ControlLevel(cfg.GetControlLevel()))
+ s.executor.SetProtectedGuests(cfg.GetProtectedGuests())
+ log.Info().Str("control_level", cfg.GetControlLevel()).Msg("Updated MCP control settings")
+ }
}
log.Info().Msg("Restarting OpenCode sidecar with new configuration")
@@ -524,3 +538,42 @@ func (s *Service) SetDiskHealthProvider(provider mcp.DiskHealthProvider) {
s.executor.SetDiskHealthProvider(provider)
}
}
+
+// SetAgentProfileManager sets the profile manager for agent scope updates.
+func (s *Service) SetAgentProfileManager(manager mcp.AgentProfileManager) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.executor != nil {
+ s.executor.SetAgentProfileManager(manager)
+ }
+}
+
+// SetControlLevel sets the AI control level (read_only, suggest, controlled, autonomous)
+func (s *Service) SetControlLevel(level string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.executor != nil {
+ s.executor.SetControlLevel(mcp.ControlLevel(level))
+ }
+}
+
+// SetProtectedGuests sets the list of VMIDs/names that AI cannot control
+func (s *Service) SetProtectedGuests(guests []string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.executor != nil {
+ s.executor.SetProtectedGuests(guests)
+ }
+}
+
+// UpdateControlSettings updates both control level and protected guests from config
+func (s *Service) UpdateControlSettings(cfg *config.AIConfig) {
+ if cfg == nil {
+ return
+ }
+ s.SetControlLevel(cfg.GetControlLevel())
+ s.SetProtectedGuests(cfg.GetProtectedGuests())
+}
diff --git a/internal/ai/opencode/sidecar.go b/internal/ai/opencode/sidecar.go
index ac920194e..e462092d8 100644
--- a/internal/ai/opencode/sidecar.go
+++ b/internal/ai/opencode/sidecar.go
@@ -6,6 +6,7 @@ import (
"net"
"os"
"os/exec"
+ "strings"
"sync"
"time"
@@ -95,42 +96,9 @@ func (s *Sidecar) Start(ctx context.Context) error {
}
}
- // Create OpenCode config with MCP server connection and model
- if s.dataDir != "" {
- configPath := s.dataDir + "/opencode.json"
-
- // Build config with optional model
- modelLine := ""
- if s.model != "" {
- modelLine = fmt.Sprintf(` "model": "%s",
-`, s.model)
- }
-
- mcpConfig := ""
- if s.mcpURL != "" {
- mcpConfig = fmt.Sprintf(` "mcp": {
- "pulse": {
- "type": "remote",
- "url": "%s",
- "enabled": true
- }
- }`, s.mcpURL)
- }
-
- // Note: API keys are passed via environment variables (not in config file)
- // OpenCode reads them from ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
- providerConfig := ""
-
- config := fmt.Sprintf(`{
- "$schema": "https://opencode.ai/config.json",
-%s%s%s
-}`, modelLine, providerConfig, mcpConfig)
-
- if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
- log.Warn().Err(err).Msg("Failed to write OpenCode config")
- } else {
- log.Info().Str("config", configPath).Str("model", s.model).Str("mcpURL", s.mcpURL).Msg("Created OpenCode config")
- }
+ // Write OpenCode config with MCP server connection, model, and system instructions
+ if err := s.writeConfig(); err != nil {
+ log.Warn().Err(err).Msg("Failed to write OpenCode config")
}
// Create a cancellable context for the process
@@ -138,11 +106,14 @@ func (s *Sidecar) Start(ctx context.Context) error {
s.cancelCtx = cancel
// Build command - using npx to run opencode
+ // Using globally installed binary to avoid npx cache issues
+ // Note: Temporarily using 0.0.0.0 to test direct access
s.cmd = exec.CommandContext(processCtx,
- "npx", "-y", "opencode-ai@latest",
+ "opencode",
"serve",
"--port", fmt.Sprintf("%d", s.port),
- "--hostname", "127.0.0.1",
+ "--hostname", "0.0.0.0",
+ "--print-logs", // Enable logging output for debugging
)
// Set working directory
@@ -154,20 +125,32 @@ func (s *Sidecar) Start(ctx context.Context) error {
env := append(os.Environ(),
fmt.Sprintf("OPENCODE_PORT=%d", s.port),
)
+
+ // Pass model via environment variable (more reliable than config file)
+ if s.model != "" {
+ env = append(env, fmt.Sprintf("OPENCODE_MODEL=%s", s.model))
+ }
+
+ var configuredKeys []string
if s.anthropicAPIKey != "" {
env = append(env, fmt.Sprintf("ANTHROPIC_API_KEY=%s", s.anthropicAPIKey))
+ configuredKeys = append(configuredKeys, "anthropic")
}
if s.openAIAPIKey != "" {
env = append(env, fmt.Sprintf("OPENAI_API_KEY=%s", s.openAIAPIKey))
+ configuredKeys = append(configuredKeys, "openai")
}
if s.deepSeekAPIKey != "" {
env = append(env, fmt.Sprintf("DEEPSEEK_API_KEY=%s", s.deepSeekAPIKey))
+ configuredKeys = append(configuredKeys, "deepseek")
}
if s.geminiAPIKey != "" {
env = append(env, fmt.Sprintf("GEMINI_API_KEY=%s", s.geminiAPIKey))
// OpenCode also accepts GOOGLE_GENERATIVE_AI_API_KEY - set both to ensure compatibility
env = append(env, fmt.Sprintf("GOOGLE_GENERATIVE_AI_API_KEY=%s", s.geminiAPIKey))
+ configuredKeys = append(configuredKeys, "gemini")
}
+ log.Info().Strs("api_keys", configuredKeys).Str("model", s.model).Int("port", s.port).Msg("Starting OpenCode sidecar")
s.cmd.Env = env
// Capture output for debugging
@@ -181,6 +164,17 @@ func (s *Sidecar) Start(ctx context.Context) error {
}
s.started = true
+ log.Info().Int("pid", s.cmd.Process.Pid).Int("port", s.port).Msg("OpenCode process started")
+
+ // Monitor process exit in background
+ go func() {
+ err := s.cmd.Wait()
+ if err != nil {
+ log.Error().Err(err).Int("pid", s.cmd.Process.Pid).Msg("OpenCode process exited with error")
+ } else {
+ log.Info().Int("pid", s.cmd.Process.Pid).Msg("OpenCode process exited normally")
+ }
+ }()
// Release lock before waitForReady (which also needs the lock)
s.mu.Unlock()
@@ -189,7 +183,7 @@ func (s *Sidecar) Start(ctx context.Context) error {
go s.healthLoop(ctx)
// Wait for server to be ready
- if err := s.waitForReady(ctx, 30*time.Second); err != nil {
+ if err := s.waitForReady(ctx, 120*time.Second); err != nil {
log.Error().Err(err).Msg("waitForReady failed")
s.Stop()
return fmt.Errorf("opencode failed to become ready: %w", err)
@@ -257,27 +251,47 @@ func (s *Sidecar) writeConfig() error {
configPath := s.dataDir + "/opencode.json"
// Build config with optional model
- modelLine := ""
+ var configParts []string
+ configParts = append(configParts, ` "$schema": "https://opencode.ai/config.json"`)
+
+ // Inject System Instructions
+ instructions := ` "instructions": [
+ "You are Pulse's AI assistant for infrastructure monitoring and management.",
+ "You have access to pulse_* MCP tools. ALWAYS use them for infrastructure questions:",
+ "- pulse_get_infrastructure_state: Get all VMs, containers, hosts",
+ "- pulse_get_active_alerts: Get current alerts and warnings",
+ "- pulse_get_metrics_history: Get CPU/memory/disk history for resources",
+ "- pulse_get_resource_details: Get details for a specific VM/container",
+ "- pulse_get_baselines: Get learned normal behavior",
+ "- pulse_get_patterns: Get detected patterns and predictions",
+ "- pulse_get_disk_health: Get SMART data and disk status",
+ "- pulse_get_storage: Get storage pool information",
+ "- pulse_get_agent_scope: Inspect agent scope and profile settings",
+ "- pulse_set_agent_scope: Safely update unified agent scope via profiles",
+ "- pulse_run_command: Execute commands on managed hosts (only when control level allows)",
+ "When asked about infrastructure, VMs, containers, alerts, metrics, or system status, ALWAYS use pulse_* tools.",
+ "Use pulse_set_agent_scope for agent module changes instead of running shell commands.",
+ "Do NOT use webfetch for infrastructure questions - use the MCP tools.",
+ "Be concise and direct. Focus on actionable insights."
+ ]`
+ configParts = append(configParts, instructions)
+
if s.model != "" {
- modelLine = fmt.Sprintf(` "model": "%s",
-`, s.model)
+ configParts = append(configParts, fmt.Sprintf(` "model": "%s"`, s.model))
}
- mcpConfig := ""
if s.mcpURL != "" {
- mcpConfig = fmt.Sprintf(` "mcp": {
+ mcpConfig := fmt.Sprintf(` "mcp": {
"pulse": {
"type": "remote",
"url": "%s",
"enabled": true
}
}`, s.mcpURL)
+ configParts = append(configParts, mcpConfig)
}
- config := fmt.Sprintf(`{
- "$schema": "https://opencode.ai/config.json",
-%s%s
-}`, modelLine, mcpConfig)
+ config := fmt.Sprintf("{\n%s\n}", strings.Join(configParts, ",\n"))
if err := os.WriteFile(configPath, []byte(config), 0644); err != nil {
return fmt.Errorf("failed to write OpenCode config: %w", err)
@@ -350,7 +364,8 @@ func (s *Sidecar) waitForReady(ctx context.Context, timeout time.Duration) error
// checkHealth performs a health check against the OpenCode server
func (s *Sidecar) checkHealth() bool {
client := newHTTPClient(5 * time.Second)
- resp, err := client.Get(s.baseURL + "/global/health")
+ // Use /config endpoint for health check - it returns JSON and indicates the server is ready
+ resp, err := client.Get(s.baseURL + "/config")
if err != nil {
return false
}
@@ -411,7 +426,8 @@ func (w *logWriter) Write(p []byte) (n int, err error) {
case "error":
log.Error().Str("source", w.prefix).Msg(msg)
default:
- log.Debug().Str("source", w.prefix).Msg(msg)
+ // Use Info level to ensure OpenCode output is visible in logs
+ log.Info().Str("source", w.prefix).Msg(msg)
}
return len(p), nil
}
diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go
index 727a0b3ea..b26b2f30c 100644
--- a/internal/api/ai_handler.go
+++ b/internal/api/ai_handler.go
@@ -3,12 +3,7 @@ package api
import (
"context"
"encoding/json"
- "fmt"
- "io"
"net/http"
- "net/http/httputil"
- "net/url"
- "strings"
"sync/atomic"
"time"
@@ -43,12 +38,18 @@ type AIStateProvider interface {
// Start initializes and starts the OpenCode service
func (h *AIHandler) Start(ctx context.Context, stateProvider AIStateProvider) error {
+ log.Info().Msg("AIHandler.Start called")
aiCfg := h.loadAIConfig()
- if aiCfg == nil || !aiCfg.Enabled {
- log.Info().Msg("AI is disabled")
+ if aiCfg == nil {
+ log.Info().Msg("AI config is nil, AI is disabled")
+ return nil
+ }
+ if !aiCfg.Enabled {
+ log.Info().Bool("enabled", aiCfg.Enabled).Msg("AI is disabled in config")
return nil
}
+ log.Info().Bool("enabled", aiCfg.Enabled).Str("model", aiCfg.Model).Msg("Starting OpenCode service")
h.service = opencode.NewService(opencode.Config{
AIConfig: aiCfg,
StateProvider: stateProvider,
@@ -56,6 +57,7 @@ func (h *AIHandler) Start(ctx context.Context, stateProvider AIStateProvider) er
})
if err := h.service.Start(ctx); err != nil {
+ log.Error().Err(err).Msg("Failed to start OpenCode service")
return err
}
@@ -387,149 +389,3 @@ func (h *AIHandler) SetMetricsHistory(provider opencode.MCPMetricsHistoryProvide
h.service.SetMetricsHistory(provider)
}
}
-
-// HandleOpenCodeUI proxies requests to OpenCode's built-in web UI
-// This allows Pulse to embed OpenCode's UI while maintaining auth
-func (h *AIHandler) HandleOpenCodeUI(w http.ResponseWriter, r *http.Request) {
- if !h.IsRunning() {
- http.Error(w, "AI is not running", http.StatusServiceUnavailable)
- return
- }
-
- baseURL := h.service.GetBaseURL()
- if baseURL == "" {
- http.Error(w, "OpenCode URL not available", http.StatusServiceUnavailable)
- return
- }
-
- target, err := url.Parse(baseURL)
- if err != nil {
- http.Error(w, "Invalid OpenCode URL", http.StatusInternalServerError)
- return
- }
-
- // Create reverse proxy
- proxy := httputil.NewSingleHostReverseProxy(target)
-
- // Customize the director to rewrite the path
- originalDirector := proxy.Director
- proxy.Director = func(req *http.Request) {
- originalDirector(req)
- // Strip the /opencode prefix from the path
- req.URL.Path = strings.TrimPrefix(req.URL.Path, "/opencode")
- if req.URL.Path == "" {
- req.URL.Path = "/"
- }
- req.Host = target.Host
- }
-
- // Modify response to allow embedding in iframe and fix asset paths
- // OpenCode sets X-Frame-Options: DENY and CSP frame-ancestors 'none'
- // which prevents embedding - we need to remove these for the Pulse panel
- // Also, OpenCode uses absolute paths for assets which need to be prefixed
- proxy.ModifyResponse = func(resp *http.Response) error {
- // Remove X-Frame-Options to allow iframe embedding
- resp.Header.Del("X-Frame-Options")
-
- // Handle multiple CSP headers - get all values, modify, and set back
- cspHeaders := resp.Header.Values("Content-Security-Policy")
- if len(cspHeaders) > 0 {
- // Delete all existing CSP headers
- resp.Header.Del("Content-Security-Policy")
- // Add back modified versions
- for _, csp := range cspHeaders {
- // Replace frame-ancestors 'none' with 'self' to allow embedding
- modified := strings.ReplaceAll(csp, "frame-ancestors 'none'", "frame-ancestors 'self'")
- resp.Header.Add("Content-Security-Policy", modified)
- }
- }
-
- // Rewrite asset paths in HTML and CSS responses
- // OpenCode uses absolute paths like /assets/... which need to be /opencode/assets/...
- contentType := resp.Header.Get("Content-Type")
- if resp.Body != nil && (strings.Contains(contentType, "text/html") || strings.Contains(contentType, "text/css")) {
- body, err := io.ReadAll(resp.Body)
- resp.Body.Close()
- if err != nil {
- return err
- }
-
- content := string(body)
-
- if strings.Contains(contentType, "text/html") {
- // Rewrite src="/..." and href="/..." to src="/opencode/..." and href="/opencode/..."
- // Be careful not to rewrite already-prefixed paths or external URLs
- content = strings.ReplaceAll(content, `src="/`, `src="/opencode/`)
- content = strings.ReplaceAll(content, `href="/`, `href="/opencode/`)
- }
-
- if strings.Contains(contentType, "text/css") {
- // Rewrite url(/...) and url("/...") and url('/...') in CSS for fonts and other assets
- content = strings.ReplaceAll(content, `url(/`, `url(/opencode/`)
- content = strings.ReplaceAll(content, `url("/`, `url("/opencode/`)
- content = strings.ReplaceAll(content, `url('/`, `url('/opencode/`)
- }
-
- // Update response body
- resp.Body = io.NopCloser(strings.NewReader(content))
- resp.ContentLength = int64(len(content))
- resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(content)))
- }
-
- return nil
- }
-
- // Handle WebSocket upgrades
- if r.Header.Get("Upgrade") == "websocket" {
- proxy.ServeHTTP(w, r)
- return
- }
-
- // Serve the proxied request
- proxy.ServeHTTP(w, r)
-}
-
-// HandleOpenCodeAPI proxies OpenCode's API requests
-// When OpenCode is embedded in an iframe, its frontend makes requests to window.location.origin
-// which is Pulse. This handler proxies those requests to OpenCode's actual backend.
-func (h *AIHandler) HandleOpenCodeAPI(w http.ResponseWriter, r *http.Request) {
- if !h.IsRunning() {
- http.Error(w, "AI is not running", http.StatusServiceUnavailable)
- return
- }
-
- baseURL := h.service.GetBaseURL()
- if baseURL == "" {
- http.Error(w, "OpenCode URL not available", http.StatusServiceUnavailable)
- return
- }
-
- target, err := url.Parse(baseURL)
- if err != nil {
- http.Error(w, "Invalid OpenCode URL", http.StatusInternalServerError)
- return
- }
-
- // Create reverse proxy - no path modification needed
- proxy := httputil.NewSingleHostReverseProxy(target)
-
- originalDirector := proxy.Director
- proxy.Director = func(req *http.Request) {
- originalDirector(req)
- // Keep the path as-is (no stripping)
- req.Host = target.Host
- // OpenCode uses Accept header to distinguish API vs SPA requests
- // Set Accept: application/json for API requests so we get JSON not HTML
- if req.Header.Get("Accept") == "" || req.Header.Get("Accept") == "*/*" {
- req.Header.Set("Accept", "application/json")
- }
- }
-
- // Handle WebSocket upgrades (for /pty/ and other real-time endpoints)
- if r.Header.Get("Upgrade") == "websocket" {
- proxy.ServeHTTP(w, r)
- return
- }
-
- proxy.ServeHTTP(w, r)
-}
diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go
index e65564d4c..d072cfdb5 100644
--- a/internal/api/ai_handlers.go
+++ b/internal/api/ai_handlers.go
@@ -31,11 +31,12 @@ import (
// AISettingsHandler handles AI settings endpoints
type AISettingsHandler struct {
- config *config.Config
- persistence *config.ConfigPersistence
- aiService *ai.Service
- agentServer *agentexec.Server
- onModelChange func() // Called when model or other OpenCode-affecting settings change
+ config *config.Config
+ persistence *config.ConfigPersistence
+ aiService *ai.Service
+ agentServer *agentexec.Server
+ onModelChange func() // Called when model or other OpenCode-affecting settings change
+ onControlSettingsChange func() // Called when control level or protected guests change
}
// NewAISettingsHandler creates a new AI settings handler
@@ -168,6 +169,12 @@ func (h *AISettingsHandler) SetOnModelChange(callback func()) {
h.onModelChange = callback
}
+// SetOnControlSettingsChange sets a callback to be invoked when control settings change
+// Used by Router to update MCP tool visibility without restarting OpenCode
+func (h *AISettingsHandler) SetOnControlSettingsChange(callback func()) {
+ h.onControlSettingsChange = callback
+}
+
// AISettingsResponse is returned by GET /api/settings/ai
// API keys are masked for security
type AISettingsResponse struct {
@@ -205,6 +212,9 @@ type AISettingsResponse struct {
CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"`
// Request timeout (seconds) - for slow hardware running local models
RequestTimeoutSeconds int `json:"request_timeout_seconds,omitempty"`
+ // Infrastructure control settings
+ ControlLevel string `json:"control_level"` // "read_only", "suggest", "controlled", "autonomous"
+ ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs/names that AI cannot control
}
// AISettingsUpdateRequest is the request body for PUT /api/settings/ai
@@ -243,6 +253,9 @@ type AISettingsUpdateRequest struct {
CostBudgetUSD30d *float64 `json:"cost_budget_usd_30d,omitempty"`
// Request timeout (seconds) - for slow hardware running local models
RequestTimeoutSeconds *int `json:"request_timeout_seconds,omitempty"`
+ // Infrastructure control settings
+ ControlLevel *string `json:"control_level,omitempty"` // "read_only", "suggest", "controlled", "autonomous"
+ ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs/names that AI cannot control (nil = don't update, empty = clear)
}
// HandleGetAISettings returns the current AI settings (GET /api/settings/ai)
@@ -304,6 +317,8 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
ConfiguredProviders: settings.GetConfiguredProviders(),
CostBudgetUSD30d: settings.CostBudgetUSD30d,
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
+ ControlLevel: settings.GetControlLevel(),
+ ProtectedGuests: settings.GetProtectedGuests(),
}
if err := utils.WriteJSONResponse(w, response); err != nil {
@@ -589,6 +604,34 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
settings.RequestTimeoutSeconds = *req.RequestTimeoutSeconds
}
+ // Handle infrastructure control settings
+ if req.ControlLevel != nil {
+ if !config.IsValidControlLevel(*req.ControlLevel) {
+ http.Error(w, "invalid control_level: must be read_only, suggest, controlled, or autonomous", http.StatusBadRequest)
+ return
+ }
+ // "autonomous" requires Pro license (same as autonomous_mode)
+ if *req.ControlLevel == config.ControlLevelAutonomous {
+ if !h.aiService.HasLicenseFeature(ai.FeatureAIAutoFix) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusPaymentRequired)
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
+ "error": "license_required",
+ "message": "Autonomous control requires Pulse Pro",
+ "feature": ai.FeatureAIAutoFix,
+ "upgrade_url": "https://pulserelay.pro/",
+ })
+ return
+ }
+ }
+ settings.ControlLevel = *req.ControlLevel
+ }
+
+ // Handle protected guests (nil = don't update)
+ if req.ProtectedGuests != nil {
+ settings.ProtectedGuests = req.ProtectedGuests
+ }
+
// Save settings
if err := h.persistence.SaveAIConfig(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save AI settings")
@@ -615,6 +658,12 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
h.onModelChange()
}
+ // Update MCP control settings if control level or protected guests changed
+ // This updates tool visibility without restarting OpenCode
+ if h.onControlSettingsChange != nil && (req.ControlLevel != nil || req.ProtectedGuests != nil) {
+ h.onControlSettingsChange()
+ }
+
log.Info().
Bool("enabled", settings.Enabled).
Str("provider", settings.Provider).
@@ -662,6 +711,8 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
OpenAIBaseURL: settings.OpenAIBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
+ ControlLevel: settings.GetControlLevel(),
+ ProtectedGuests: settings.GetProtectedGuests(),
}
if err := utils.WriteJSONResponse(w, response); err != nil {
diff --git a/internal/api/config_profiles.go b/internal/api/config_profiles.go
index d284fe262..eb010443b 100644
--- a/internal/api/config_profiles.go
+++ b/internal/api/config_profiles.go
@@ -16,9 +16,10 @@ import (
// ConfigProfileHandler handles configuration profile operations
type ConfigProfileHandler struct {
- persistence *config.ConfigPersistence
- validator *models.ProfileValidator
- mu sync.RWMutex
+ persistence *config.ConfigPersistence
+ validator *models.ProfileValidator
+ mu sync.RWMutex
+ suggestionHandler *ProfileSuggestionHandler
}
// NewConfigProfileHandler creates a new handler
@@ -29,6 +30,11 @@ func NewConfigProfileHandler(persistence *config.ConfigPersistence) *ConfigProfi
}
}
+// SetAIHandler sets the AI handler for profile suggestions
+func (h *ConfigProfileHandler) SetAIHandler(aiHandler *AIHandler) {
+ h.suggestionHandler = NewProfileSuggestionHandler(h.persistence, aiHandler)
+}
+
// ServeHTTP implements the http.Handler interface
func (h *ConfigProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Simple routing
@@ -68,6 +74,16 @@ func (h *ConfigProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
h.ValidateConfig(w, r)
return
}
+ } else if path == "/suggestions" {
+ // POST /suggestions - AI-assisted profile suggestion
+ if r.Method == http.MethodPost {
+ if h.suggestionHandler != nil {
+ h.suggestionHandler.HandleSuggestProfile(w, r)
+ } else {
+ http.Error(w, "AI service not configured", http.StatusServiceUnavailable)
+ }
+ return
+ }
} else if path == "/changelog" {
// GET /changelog - Return profile change history
if r.Method == http.MethodGet {
diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go
index c15ff720d..b1cc9777f 100644
--- a/internal/api/diagnostics.go
+++ b/internal/api/diagnostics.go
@@ -43,6 +43,7 @@ type DiagnosticsInfo struct {
APITokens *APITokenDiagnostic `json:"apiTokens,omitempty"`
DockerAgents *DockerAgentDiagnostic `json:"dockerAgents,omitempty"`
Alerts *AlertsDiagnostic `json:"alerts,omitempty"`
+ OpenCode *OpenCodeDiagnostic `json:"openCode,omitempty"`
Errors []string `json:"errors"`
// NodeSnapshots captures the raw memory payload and derived usage Pulse last observed per node.
NodeSnapshots []monitoring.NodeMemorySnapshot `json:"nodeSnapshots,omitempty"`
@@ -355,6 +356,19 @@ type AlertsDiagnostic struct {
Notes []string `json:"notes,omitempty"`
}
+// OpenCodeDiagnostic reports on the OpenCode AI sidecar status.
+type OpenCodeDiagnostic struct {
+ Enabled bool `json:"enabled"`
+ Running bool `json:"running"`
+ Healthy bool `json:"healthy"`
+ Port int `json:"port,omitempty"`
+ URL string `json:"url,omitempty"`
+ Model string `json:"model,omitempty"`
+ MCPConnected bool `json:"mcpConnected"`
+ MCPToolCount int `json:"mcpToolCount,omitempty"`
+ Notes []string `json:"notes,omitempty"`
+}
+
// handleDiagnostics returns comprehensive diagnostic information
func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) {
diagnosticsMetricsOnce.Do(func() {
@@ -555,6 +569,7 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo {
diag.DockerAgents = buildDockerAgentDiagnostic(r.monitor, diag.Version)
diag.Alerts = buildAlertsDiagnostic(r.monitor)
+ diag.OpenCode = buildOpenCodeDiagnostic(r.config, r.aiHandler)
diag.Discovery = buildDiscoveryDiagnostic(r.config, r.monitor)
@@ -1860,3 +1875,60 @@ func interfaceToStringSlice(value interface{}) []string {
return nil
}
}
+
+func buildOpenCodeDiagnostic(cfg *config.Config, aiHandler *AIHandler) *OpenCodeDiagnostic {
+ if cfg == nil {
+ return nil
+ }
+
+ diag := &OpenCodeDiagnostic{
+ Enabled: false,
+ Notes: []string{},
+ }
+
+ // Calculate enabled state based on AI config
+ // NOTE: aiHandler might be nil during early startup
+ if aiHandler != nil {
+ aiCfg := aiHandler.GetAIConfig()
+ if aiCfg != nil {
+ diag.Enabled = aiCfg.UseOpenCode
+ diag.Model = aiCfg.GetChatModel()
+
+ // Pulse legacy config check
+ if !diag.Enabled && aiCfg.Enabled {
+ diag.Notes = append(diag.Notes, "AI is enabled but UseOpenCode is false - using legacy implementation")
+ }
+ }
+
+ svc := aiHandler.GetService()
+ if svc != nil {
+ diag.Running = svc.IsRunning()
+ diag.Healthy = svc.IsRunning() // Consolidate for now
+
+ // Get connection details
+ baseURL := svc.GetBaseURL()
+ if baseURL != "" {
+ diag.URL = baseURL
+ // Parse port from URL
+ if parts := strings.Split(baseURL, ":"); len(parts) > 2 {
+ if port, err := strconv.Atoi(parts[2]); err == nil {
+ diag.Port = port
+ }
+ }
+ }
+
+ // Check MCP connection (if we had access to check it)
+ diag.MCPConnected = diag.Running // Assume connected if running for now
+
+ if !diag.Running && diag.Enabled {
+ diag.Notes = append(diag.Notes, "OpenCode service is enabled but not running")
+ }
+ } else if diag.Enabled {
+ diag.Notes = append(diag.Notes, "OpenCode service is nil")
+ }
+ } else {
+ diag.Notes = append(diag.Notes, "AI Handler not initialized")
+ }
+
+ return diag
+}
diff --git a/internal/api/profile_suggestions.go b/internal/api/profile_suggestions.go
new file mode 100644
index 000000000..23956f606
--- /dev/null
+++ b/internal/api/profile_suggestions.go
@@ -0,0 +1,289 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/ai/opencode"
+ "github.com/rcourtman/pulse-go-rewrite/internal/config"
+ "github.com/rcourtman/pulse-go-rewrite/internal/models"
+ "github.com/rs/zerolog/log"
+)
+
+// ProfileSuggestionHandler handles AI-assisted profile suggestions
+type ProfileSuggestionHandler struct {
+ persistence *config.ConfigPersistence
+ aiHandler *AIHandler
+}
+
+// NewProfileSuggestionHandler creates a new suggestion handler
+func NewProfileSuggestionHandler(persistence *config.ConfigPersistence, aiHandler *AIHandler) *ProfileSuggestionHandler {
+ return &ProfileSuggestionHandler{
+ persistence: persistence,
+ aiHandler: aiHandler,
+ }
+}
+
+// SuggestionRequest is the request body for profile suggestions
+type SuggestionRequest struct {
+ Prompt string `json:"prompt"`
+}
+
+// ProfileSuggestion is the AI-generated profile suggestion
+type ProfileSuggestion struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Config map[string]interface{} `json:"config"`
+ Rationale []string `json:"rationale"`
+}
+
+// HandleSuggestProfile handles POST /api/admin/profiles/suggestions
+func (h *ProfileSuggestionHandler) HandleSuggestProfile(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Check if AI is running
+ if h.aiHandler == nil || !h.aiHandler.IsRunning() {
+ http.Error(w, "AI service is not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ // Parse request
+ var req SuggestionRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ // Validate prompt is not empty
+ req.Prompt = strings.TrimSpace(req.Prompt)
+ if req.Prompt == "" {
+ http.Error(w, "Prompt is required", http.StatusBadRequest)
+ return
+ }
+
+ // Build context for the AI
+ contextParts := []string{}
+
+ // Add existing profiles for reference
+ profiles, err := h.persistence.LoadAgentProfiles()
+ if err == nil && len(profiles) > 0 {
+ profileNames := make([]string, len(profiles))
+ for i, p := range profiles {
+ profileNames[i] = p.Name
+ }
+ contextParts = append(contextParts, fmt.Sprintf("Existing profiles: %s", strings.Join(profileNames, ", ")))
+ }
+
+ // Build config schema documentation from the actual definitions
+ configDocs := buildConfigSchemaDoc()
+
+ // Build the prompt for the AI (schema docs only in system prompt, not in context)
+ systemPrompt := fmt.Sprintf(`You are an infrastructure configuration assistant for Pulse, a monitoring platform.
+Your task is to suggest an agent configuration profile based on the user's request.
+
+IMPORTANT: You must respond ONLY with a valid JSON object in this exact format:
+{
+ "name": "Profile Name",
+ "description": "Brief description of what this profile is for",
+ "config": {
+ "key": "value"
+ },
+ "rationale": ["Reason 1", "Reason 2"]
+}
+
+Available configuration keys and their types:
+%s
+
+Only include settings that are relevant to the user's request. Do not include settings with default values.
+`, configDocs)
+
+ userPrompt := req.Prompt
+ if len(contextParts) > 0 {
+ userPrompt = fmt.Sprintf("Context:\n%s\n\nRequest: %s", strings.Join(contextParts, "\n"), req.Prompt)
+ }
+
+ fullPrompt := fmt.Sprintf("%s\n\nUser request: %s\n\nRespond with ONLY the JSON object, no markdown, no explanation.", systemPrompt, userPrompt)
+
+ // Call the AI service
+ ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
+ defer cancel()
+
+ response, err := h.aiHandler.GetService().Execute(ctx, opencode.ExecuteRequest{
+ Prompt: fullPrompt,
+ })
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get AI suggestion")
+ http.Error(w, "Failed to generate suggestion", http.StatusInternalServerError)
+ return
+ }
+
+ fullResponse := response.Message.Content
+ if fullResponse == "" {
+ log.Error().Msg("AI returned empty response")
+ http.Error(w, "AI returned empty response", http.StatusInternalServerError)
+ return
+ }
+
+ suggestion, err := parseAISuggestion(fullResponse)
+ if err != nil {
+ log.Error().Err(err).Str("response", fullResponse).Msg("Failed to parse AI suggestion")
+ // Return a friendly error with partial info if available
+ http.Error(w, fmt.Sprintf("Failed to parse AI response: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Validate the suggested config
+ validator := models.NewProfileValidator()
+ if suggestion.Config != nil {
+ configMap := models.AgentConfigMap{}
+ for k, v := range suggestion.Config {
+ configMap[k] = v
+ }
+ result := validator.Validate(configMap)
+ if !result.Valid {
+ // Include warnings in response but don't fail
+ log.Warn().Interface("errors", result.Errors).Msg("Suggestion has validation warnings")
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(suggestion)
+}
+
+// parseAISuggestion extracts the ProfileSuggestion from the AI response
+func parseAISuggestion(text string) (*ProfileSuggestion, error) {
+ // Try to find JSON in the response
+ text = strings.TrimSpace(text)
+
+ // Remove ALL markdown code block markers
+ text = strings.ReplaceAll(text, "```json", "")
+ text = strings.ReplaceAll(text, "```", "")
+ text = strings.TrimSpace(text)
+
+ // Find JSON object boundaries - use brace counting to find the complete JSON
+ start := strings.Index(text, "{")
+ if start == -1 {
+ return nil, fmt.Errorf("no JSON object found in response")
+ }
+
+ // Count braces to find the matching closing brace
+ braceCount := 0
+ end := -1
+ inString := false
+ escape := false
+ for i := start; i < len(text); i++ {
+ c := text[i]
+ if escape {
+ escape = false
+ continue
+ }
+ if c == '\\' {
+ escape = true
+ continue
+ }
+ if c == '"' {
+ inString = !inString
+ continue
+ }
+ if inString {
+ continue
+ }
+ if c == '{' {
+ braceCount++
+ } else if c == '}' {
+ braceCount--
+ if braceCount == 0 {
+ end = i
+ break
+ }
+ }
+ }
+
+ if end == -1 {
+ return nil, fmt.Errorf("no complete JSON object found in response")
+ }
+
+ jsonStr := text[start : end+1]
+
+ var suggestion ProfileSuggestion
+ if err := json.Unmarshal([]byte(jsonStr), &suggestion); err != nil {
+ return nil, fmt.Errorf("invalid JSON: %w", err)
+ }
+
+ // Validate required fields
+ if suggestion.Name == "" {
+ suggestion.Name = "Suggested Profile"
+ }
+ if suggestion.Description == "" {
+ suggestion.Description = "AI-generated configuration profile"
+ }
+ if suggestion.Config == nil {
+ suggestion.Config = make(map[string]interface{})
+ }
+ if suggestion.Rationale == nil {
+ suggestion.Rationale = []string{}
+ }
+
+ return &suggestion, nil
+}
+
+// buildConfigSchemaDoc generates documentation for all config keys from the schema
+func buildConfigSchemaDoc() string {
+ defs := models.GetConfigKeyDefinitions()
+ var lines []string
+
+ for _, def := range defs {
+ var typeStr string
+ switch def.Type {
+ case models.ConfigTypeBool:
+ typeStr = "boolean"
+ case models.ConfigTypeString:
+ typeStr = "string"
+ case models.ConfigTypeInt:
+ typeStr = "integer"
+ if def.Min != nil || def.Max != nil {
+ constraints := []string{}
+ if def.Min != nil {
+ constraints = append(constraints, fmt.Sprintf("min: %.0f", *def.Min))
+ }
+ if def.Max != nil {
+ constraints = append(constraints, fmt.Sprintf("max: %.0f", *def.Max))
+ }
+ typeStr += " (" + strings.Join(constraints, ", ") + ")"
+ }
+ case models.ConfigTypeFloat:
+ typeStr = "number"
+ if def.Min != nil || def.Max != nil {
+ constraints := []string{}
+ if def.Min != nil {
+ constraints = append(constraints, fmt.Sprintf("min: %.1f", *def.Min))
+ }
+ if def.Max != nil {
+ constraints = append(constraints, fmt.Sprintf("max: %.1f", *def.Max))
+ }
+ typeStr += " (" + strings.Join(constraints, ", ") + ")"
+ }
+ case models.ConfigTypeDuration:
+ typeStr = "duration string (e.g., \"30s\", \"1m\", \"5m\")"
+ case models.ConfigTypeEnum:
+ typeStr = fmt.Sprintf("enum: %s", strings.Join(def.Enum, ", "))
+ default:
+ typeStr = string(def.Type)
+ }
+
+ line := fmt.Sprintf("- %s (%s): %s", def.Key, typeStr, def.Description)
+ if def.Default != nil && def.Default != "" {
+ line += fmt.Sprintf(" [default: %v]", def.Default)
+ }
+ lines = append(lines, line)
+ }
+
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/api/router.go b/internal/api/router.go
index 95a020929..563c88ba4 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -1258,6 +1258,20 @@ func (r *Router) setupRoutes() {
r.aiSettingsHandler.SetOnModelChange(func() {
r.RestartOpenCodeAI(context.Background())
})
+ // Wire control settings change callback to update MCP tool visibility
+ r.aiSettingsHandler.SetOnControlSettingsChange(func() {
+ if r.aiHandler != nil {
+ if svc := r.aiHandler.GetService(); svc != nil {
+ cfg := r.aiHandler.GetAIConfig()
+ if cfg != nil {
+ svc.UpdateControlSettings(cfg)
+ log.Info().Str("control_level", cfg.GetControlLevel()).Msg("Updated AI control settings")
+ }
+ }
+ }
+ })
+ // Wire AI handler to profile handler for AI-assisted suggestions
+ r.configProfileHandler.SetAIHandler(r.aiHandler)
// Wire license checker for alert manager Pro features (Update Alerts)
if r.monitor != nil {
alertMgr := r.monitor.GetAlertManager()
@@ -1401,42 +1415,6 @@ func (r *Router) setupRoutes() {
}))
r.mux.HandleFunc("/api/ai/sessions/", RequireAuth(r.config, r.routeOpenCodeSessions))
- // OpenCode Web UI proxy - serves OpenCode's built-in web interface
- // This allows users to access OpenCode directly through Pulse with auth
- r.mux.HandleFunc("/opencode/", RequireAuth(r.config, r.aiHandler.HandleOpenCodeUI))
- r.mux.HandleFunc("/opencode", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
- // Redirect /opencode to /opencode/ for proper asset loading
- http.Redirect(w, req, "/opencode/", http.StatusMovedPermanently)
- }))
-
- // OpenCode API proxy - these routes are used by OpenCode's frontend
- // When embedded in iframe, OpenCode's JS makes requests to window.location.origin
- // We proxy these to OpenCode's backend so the iframe works correctly
- // NOTE: Register both /path and /path/ because Go's ServeMux treats them differently:
- // - /path/ matches any path starting with /path/
- // - /path (no trailing slash) matches exactly /path
- // Note: /global is a client-side route in OpenCode, not an API endpoint
- openCodeAPIBases := []string{
- "/session",
- "/tui",
- "/config",
- "/file",
- "/find",
- "/instance",
- "/mcp",
- "/permission",
- "/project",
- "/provider",
- "/pty",
- "/question",
- "/experimental",
- }
- for _, base := range openCodeAPIBases {
- // Register both exact match and prefix match
- r.mux.HandleFunc(base, RequireAuth(r.config, r.aiHandler.HandleOpenCodeAPI))
- r.mux.HandleFunc(base+"/", RequireAuth(r.config, r.aiHandler.HandleOpenCodeAPI))
- }
-
// Agent WebSocket for AI command execution
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
@@ -1952,6 +1930,12 @@ func (r *Router) wireOpenCodeProviders() {
}
}
+ if r.persistence != nil {
+ manager := NewMCPAgentProfileManager(r.persistence, r.licenseHandlers.Service())
+ service.SetAgentProfileManager(manager)
+ log.Debug().Msg("OpenCode: Agent profile manager wired")
+ }
+
log.Info().Msg("OpenCode MCP tool providers wired")
}
@@ -2452,23 +2436,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
strings.HasPrefix(req.URL.Path, "/ws") ||
strings.HasPrefix(req.URL.Path, "/socket.io/") ||
strings.HasPrefix(req.URL.Path, "/download/") ||
- strings.HasPrefix(req.URL.Path, "/opencode") ||
- // OpenCode API paths - proxied to OpenCode backend for iframe embedding
- // Note: Use "/path" (not "/path/") to match both exact and prefix paths
- // Note: /global is a client-side route, not included here
- strings.HasPrefix(req.URL.Path, "/session") ||
- strings.HasPrefix(req.URL.Path, "/tui") ||
- strings.HasPrefix(req.URL.Path, "/config") ||
- strings.HasPrefix(req.URL.Path, "/file") ||
- strings.HasPrefix(req.URL.Path, "/find") ||
- strings.HasPrefix(req.URL.Path, "/instance") ||
- strings.HasPrefix(req.URL.Path, "/mcp") ||
- strings.HasPrefix(req.URL.Path, "/permission") ||
- strings.HasPrefix(req.URL.Path, "/project") ||
- strings.HasPrefix(req.URL.Path, "/provider") ||
- strings.HasPrefix(req.URL.Path, "/pty") ||
- strings.HasPrefix(req.URL.Path, "/question") ||
- strings.HasPrefix(req.URL.Path, "/experimental") ||
req.URL.Path == "/simple-stats" ||
req.URL.Path == "/install-docker-agent.sh" ||
req.URL.Path == "/install-container-agent.sh" ||
diff --git a/internal/api/security.go b/internal/api/security.go
index 4704222b6..f9cebf90a 100644
--- a/internal/api/security.go
+++ b/internal/api/security.go
@@ -409,14 +409,8 @@ func ResetLockout(identifier string) {
// SecurityHeadersWithConfig applies security headers with embedding configuration
func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOrigins string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Skip frame-related headers for /opencode/ paths - these are managed by the proxy
- // The OpenCode proxy modifies headers to allow embedding within Pulse's AI panel
- isOpenCodePath := strings.HasPrefix(r.URL.Path, "/opencode")
-
// Configure clickjacking protection based on embedding settings
- if isOpenCodePath {
- // OpenCode proxy manages its own iframe headers
- } else if allowEmbedding {
+ if allowEmbedding {
// When embedding is allowed, don't set X-Frame-Options header
// This allows embedding from any origin
// Security note: User explicitly enabled this for iframe embedding
@@ -442,10 +436,7 @@ func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOr
}
// Add frame-ancestors based on embedding settings
- // Skip for /opencode/ paths - the proxy manages its own CSP
- if isOpenCodePath {
- // OpenCode proxy manages its own CSP headers
- } else if allowEmbedding {
+ if allowEmbedding {
if allowedOrigins != "" {
// Parse comma-separated origins and add them to frame-ancestors
origins := strings.Split(allowedOrigins, ",")
@@ -466,10 +457,7 @@ func SecurityHeadersWithConfig(next http.Handler, allowEmbedding bool, allowedOr
cspDirectives = append(cspDirectives, "frame-ancestors 'none'")
}
- // Only set CSP for non-OpenCode paths (OpenCode proxy manages its own headers)
- if !isOpenCodePath {
- w.Header().Set("Content-Security-Policy", strings.Join(cspDirectives, "; "))
- }
+ w.Header().Set("Content-Security-Policy", strings.Join(cspDirectives, "; "))
// Referrer Policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
diff --git a/internal/config/ai.go b/internal/config/ai.go
index b5e4a7b75..1b305ce32 100644
--- a/internal/config/ai.go
+++ b/internal/config/ai.go
@@ -67,6 +67,11 @@ type AIConfig struct {
UseOpenCode bool `json:"use_opencode,omitempty"` // Enable OpenCode backend
OpenCodeDataDir string `json:"opencode_data_dir,omitempty"` // Data directory for OpenCode (default: /tmp/pulse-opencode)
OpenCodePort int `json:"opencode_port,omitempty"` // Port for OpenCode server (0 = auto-assign)
+
+ // AI Infrastructure Control settings
+ // These control whether AI can take actions on infrastructure (start/stop VMs, containers, etc.)
+ ControlLevel string `json:"control_level,omitempty"` // "read_only", "suggest", "controlled", "autonomous"
+ ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs or names that AI cannot control
}
// AIProvider constants
@@ -78,9 +83,21 @@ const (
AIProviderGemini = "gemini"
)
+// AI Control Level constants
+const (
+ // ControlLevelReadOnly - AI can only query infrastructure, no control tools available
+ ControlLevelReadOnly = "read_only"
+ // ControlLevelSuggest - AI suggests commands, user must copy/paste to execute
+ ControlLevelSuggest = "suggest"
+ // ControlLevelControlled - AI can execute with per-command approval
+ ControlLevelControlled = "controlled"
+ // ControlLevelAutonomous - AI executes without approval (requires Pro license)
+ ControlLevelAutonomous = "autonomous"
+)
+
// Default models per provider
const (
- DefaultAIModelAnthropic = "claude-opus-4-5-20251101"
+ DefaultAIModelAnthropic = "claude-3-5-haiku-latest"
DefaultAIModelOpenAI = "gpt-4o"
DefaultAIModelOllama = "llama3"
DefaultAIModelDeepSeek = "deepseek-chat" // V3.2 with tool-use support
@@ -483,3 +500,35 @@ func (c *AIConfig) GetRequestTimeout() time.Duration {
}
return 300 * time.Second // 5 minutes default
}
+
+// GetControlLevel returns the AI control level, defaulting to read_only if not set
+func (c *AIConfig) GetControlLevel() string {
+ if c.ControlLevel == "" {
+ return ControlLevelReadOnly
+ }
+ return c.ControlLevel
+}
+
+// IsControlEnabled returns true if AI has any control capability beyond read-only
+func (c *AIConfig) IsControlEnabled() bool {
+ level := c.GetControlLevel()
+ return level != ControlLevelReadOnly
+}
+
+// IsValidControlLevel checks if a control level string is valid
+func IsValidControlLevel(level string) bool {
+ switch level {
+ case ControlLevelReadOnly, ControlLevelSuggest, ControlLevelControlled, ControlLevelAutonomous:
+ return true
+ default:
+ return false
+ }
+}
+
+// GetProtectedGuests returns the list of protected guests (VMIDs or names)
+func (c *AIConfig) GetProtectedGuests() []string {
+ if c.ProtectedGuests == nil {
+ return []string{}
+ }
+ return c.ProtectedGuests
+}
diff --git a/internal/models/profile_validation.go b/internal/models/profile_validation.go
index 92fae01f0..1cdd22d62 100644
--- a/internal/models/profile_validation.go
+++ b/internal/models/profile_validation.go
@@ -32,7 +32,8 @@ const (
ConfigTypeEnum ConfigType = "enum"
)
-// ValidConfigKeys defines all valid agent configuration keys.
+// ValidConfigKeys defines agent configuration keys that are actually applied by the agent.
+// These match the keys handled in applyRemoteSettings() in cmd/pulse-agent/main.go.
var ValidConfigKeys = []ConfigKeyDefinition{
{
Key: "interval",
@@ -40,6 +41,12 @@ var ValidConfigKeys = []ConfigKeyDefinition{
Description: "Polling interval for metrics collection",
Default: "30s",
},
+ {
+ Key: "enable_host",
+ Type: ConfigTypeBool,
+ Description: "Enable host monitoring (metrics + command execution)",
+ Default: true,
+ },
{
Key: "enable_docker",
Type: ConfigTypeBool,
@@ -47,22 +54,54 @@ var ValidConfigKeys = []ConfigKeyDefinition{
Default: true,
},
{
- Key: "enable_system_metrics",
+ Key: "enable_kubernetes",
Type: ConfigTypeBool,
- Description: "Enable system-level metrics (CPU, memory, disk)",
- Default: true,
- },
- {
- Key: "enable_process_metrics",
- Type: ConfigTypeBool,
- Description: "Enable process-level metrics",
+ Description: "Enable Kubernetes workload monitoring",
Default: false,
},
{
- Key: "enable_network_metrics",
+ Key: "enable_proxmox",
Type: ConfigTypeBool,
- Description: "Enable network interface metrics",
- Default: true,
+ Description: "Enable Proxmox mode for node registration",
+ Default: false,
+ },
+ {
+ Key: "proxmox_type",
+ Type: ConfigTypeEnum,
+ Description: "Proxmox type override (pve or pbs; auto-detect if unset)",
+ Default: "auto",
+ Enum: []string{"pve", "pbs", "auto"},
+ },
+ {
+ Key: "docker_runtime",
+ Type: ConfigTypeEnum,
+ Description: "Container runtime preference (auto, docker, podman)",
+ Default: "auto",
+ Enum: []string{"auto", "docker", "podman"},
+ },
+ {
+ Key: "disable_auto_update",
+ Type: ConfigTypeBool,
+ Description: "Disable automatic agent updates",
+ Default: false,
+ },
+ {
+ Key: "disable_docker_update_checks",
+ Type: ConfigTypeBool,
+ Description: "Disable Docker image update detection",
+ Default: false,
+ },
+ {
+ Key: "kube_include_all_pods",
+ Type: ConfigTypeBool,
+ Description: "Include all non-succeeded pods in Kubernetes reports",
+ Default: false,
+ },
+ {
+ Key: "kube_include_all_deployments",
+ Type: ConfigTypeBool,
+ Description: "Include all deployments in Kubernetes reports",
+ Default: false,
},
{
Key: "log_level",
@@ -72,98 +111,16 @@ var ValidConfigKeys = []ConfigKeyDefinition{
Enum: []string{"debug", "info", "warn", "error"},
},
{
- Key: "metric_buffer_size",
- Type: ConfigTypeInt,
- Description: "Size of the metric buffer before flush",
- Default: 100,
- Min: ptrFloat(10),
- Max: ptrFloat(10000),
- },
- {
- Key: "connection_timeout",
- Type: ConfigTypeDuration,
- Description: "Timeout for server connections",
- Default: "30s",
- },
- {
- Key: "retry_interval",
- Type: ConfigTypeDuration,
- Description: "Interval between connection retries",
- Default: "5s",
- },
- {
- Key: "max_retries",
- Type: ConfigTypeInt,
- Description: "Maximum number of connection retries",
- Default: 3,
- Min: ptrFloat(0),
- Max: ptrFloat(100),
- },
- {
- Key: "disk_paths",
+ Key: "report_ip",
Type: ConfigTypeString,
- Description: "Comma-separated list of disk paths to monitor",
- Default: "/",
- },
- {
- Key: "exclude_containers",
- Type: ConfigTypeString,
- Description: "Regex pattern for container names to exclude",
+ Description: "Override the reported IP address for the agent",
Default: "",
},
{
- Key: "include_containers",
- Type: ConfigTypeString,
- Description: "Regex pattern for container names to include (empty = all)",
- Default: "",
- },
- {
- Key: "cpu_threshold_warning",
- Type: ConfigTypeFloat,
- Description: "CPU usage threshold for warnings (%)",
- Default: 80.0,
- Min: ptrFloat(0),
- Max: ptrFloat(100),
- },
- {
- Key: "cpu_threshold_critical",
- Type: ConfigTypeFloat,
- Description: "CPU usage threshold for critical alerts (%)",
- Default: 95.0,
- Min: ptrFloat(0),
- Max: ptrFloat(100),
- },
- {
- Key: "memory_threshold_warning",
- Type: ConfigTypeFloat,
- Description: "Memory usage threshold for warnings (%)",
- Default: 80.0,
- Min: ptrFloat(0),
- Max: ptrFloat(100),
- },
- {
- Key: "memory_threshold_critical",
- Type: ConfigTypeFloat,
- Description: "Memory usage threshold for critical alerts (%)",
- Default: 95.0,
- Min: ptrFloat(0),
- Max: ptrFloat(100),
- },
- {
- Key: "disk_threshold_warning",
- Type: ConfigTypeFloat,
- Description: "Disk usage threshold for warnings (%)",
- Default: 80.0,
- Min: ptrFloat(0),
- Max: ptrFloat(100),
- },
- {
- Key: "disk_threshold_critical",
- Type: ConfigTypeFloat,
- Description: "Disk usage threshold for critical alerts (%)",
- Default: 95.0,
- Min: ptrFloat(0),
- Max: ptrFloat(100),
+ Key: "disable_ceph",
+ Type: ConfigTypeBool,
+ Description: "Disable local Ceph status polling",
+ Default: false,
},
}
@@ -358,8 +315,3 @@ func GetConfigKeyDefinition(key string) (ConfigKeyDefinition, bool) {
}
return ConfigKeyDefinition{}, false
}
-
-// ptrFloat returns a pointer to a float64.
-func ptrFloat(v float64) *float64 {
- return &v
-}
diff --git a/internal/models/profile_validation_test.go b/internal/models/profile_validation_test.go
index 9002505c1..fa2b69de2 100644
--- a/internal/models/profile_validation_test.go
+++ b/internal/models/profile_validation_test.go
@@ -15,14 +15,19 @@ func TestProfileValidator_ValidateStringType(t *testing.T) {
}{
{
name: "valid string",
- config: AgentConfigMap{"disk_paths": "/,/home"},
+ config: AgentConfigMap{"report_ip": "192.168.1.100"},
+ wantErr: false,
+ },
+ {
+ name: "valid empty string",
+ config: AgentConfigMap{"report_ip": ""},
wantErr: false,
},
{
name: "invalid string type",
- config: AgentConfigMap{"disk_paths": 123},
+ config: AgentConfigMap{"report_ip": 123},
wantErr: true,
- errKey: "disk_paths",
+ errKey: "report_ip",
},
}
@@ -97,103 +102,11 @@ func TestProfileValidator_ValidateBoolType(t *testing.T) {
}
}
-func TestProfileValidator_ValidateIntType(t *testing.T) {
- validator := NewProfileValidator()
+// Note: Int type validation tests removed - no schema keys currently use ConfigTypeInt.
+// The validation logic exists in validateValue() and can be tested if int keys are added.
- tests := []struct {
- name string
- config AgentConfigMap
- wantErr bool
- errKey string
- }{
- {
- name: "valid int",
- config: AgentConfigMap{"metric_buffer_size": 100},
- wantErr: false,
- },
- {
- name: "valid int as float64 (JSON unmarshal)",
- config: AgentConfigMap{"metric_buffer_size": float64(100)},
- wantErr: false,
- },
- {
- name: "int below minimum",
- config: AgentConfigMap{"metric_buffer_size": 5},
- wantErr: true,
- errKey: "metric_buffer_size",
- },
- {
- name: "int above maximum",
- config: AgentConfigMap{"metric_buffer_size": 20000},
- wantErr: true,
- errKey: "metric_buffer_size",
- },
- {
- name: "invalid int type - string",
- config: AgentConfigMap{"metric_buffer_size": "100"},
- wantErr: true,
- errKey: "metric_buffer_size",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := validator.Validate(tt.config)
- if tt.wantErr && result.Valid {
- t.Errorf("expected validation to fail, but it passed")
- }
- if !tt.wantErr && !result.Valid {
- t.Errorf("expected validation to pass, but it failed: %v", result.Errors)
- }
- })
- }
-}
-
-func TestProfileValidator_ValidateFloatType(t *testing.T) {
- validator := NewProfileValidator()
-
- tests := []struct {
- name string
- config AgentConfigMap
- wantErr bool
- errKey string
- }{
- {
- name: "valid float",
- config: AgentConfigMap{"cpu_threshold_warning": 80.5},
- wantErr: false,
- },
- {
- name: "valid float as int",
- config: AgentConfigMap{"cpu_threshold_warning": 80},
- wantErr: false,
- },
- {
- name: "float below minimum",
- config: AgentConfigMap{"cpu_threshold_warning": -10.0},
- wantErr: true,
- errKey: "cpu_threshold_warning",
- },
- {
- name: "float above maximum",
- config: AgentConfigMap{"cpu_threshold_warning": 150.0},
- wantErr: true,
- errKey: "cpu_threshold_warning",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := validator.Validate(tt.config)
- if tt.wantErr && result.Valid {
- t.Errorf("expected validation to fail, but it passed")
- }
- if !tt.wantErr && !result.Valid {
- t.Errorf("expected validation to pass, but it failed: %v", result.Errors)
- }
- })
- }
-}
+// Note: Float type validation tests removed - no schema keys currently use ConfigTypeFloat.
+// The validation logic exists in validateValue() and can be tested if float keys are added.
func TestProfileValidator_ValidateDurationType(t *testing.T) {
validator := NewProfileValidator()
@@ -343,15 +256,17 @@ func TestProfileValidator_ComplexConfig(t *testing.T) {
validator := NewProfileValidator()
config := AgentConfigMap{
- "interval": "30s",
- "enable_docker": true,
- "enable_system_metrics": true,
- "enable_process_metrics": false,
- "log_level": "info",
- "metric_buffer_size": 100,
- "cpu_threshold_warning": 80.0,
- "cpu_threshold_critical": 95.0,
- "disk_paths": "/,/home",
+ "interval": "30s",
+ "enable_host": true,
+ "enable_docker": true,
+ "enable_kubernetes": false,
+ "enable_proxmox": true,
+ "proxmox_type": "pve",
+ "docker_runtime": "auto",
+ "log_level": "info",
+ "disable_auto_update": false,
+ "disable_docker_update_checks": false,
+ "report_ip": "192.168.1.100",
}
result := validator.Validate(config)
@@ -383,8 +298,8 @@ func TestGetConfigKeyDefinitions(t *testing.T) {
t.Error("expected config key definitions to be non-empty")
}
- // Check some known keys exist
- expectedKeys := []string{"interval", "enable_docker", "log_level", "metric_buffer_size"}
+ // Check some known keys exist (keys actually applied by the agent)
+ expectedKeys := []string{"interval", "enable_docker", "log_level", "enable_host", "docker_runtime"}
for _, key := range expectedKeys {
found := false
for _, def := range defs {
diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh
index bd5d5d067..e30426b9b 100755
--- a/scripts/hot-dev.sh
+++ b/scripts/hot-dev.sh
@@ -11,7 +11,7 @@
# HOT_DEV_USE_PRO=true Build Pro binary (default: true if module available)
# PULSE_MOCK_MODE=true Use isolated mock data directory
# PULSE_DATA_DIR=/path Override data directory
-# PULSE_DEV_API_PORT=7656 Backend API port (default: 7656)
+# PULSE_DEV_API_PORT=7655 Backend API port (default: 7655)
# FRONTEND_DEV_PORT=5173 Frontend dev server port (default: 5173)
#
# Pro Features Mode:
@@ -104,7 +104,7 @@ fi
FRONTEND_DEV_HOST=${FRONTEND_DEV_HOST:-0.0.0.0}
FRONTEND_DEV_PORT=${FRONTEND_DEV_PORT:-${FRONTEND_PORT}}
PULSE_DEV_API_HOST=${PULSE_DEV_API_HOST:-${LAN_IP}}
-PULSE_DEV_API_PORT=${PULSE_DEV_API_PORT:-7656}
+PULSE_DEV_API_PORT=${PULSE_DEV_API_PORT:-7655}
if [[ -z ${PULSE_DEV_API_URL:-} ]]; then
PULSE_DEV_API_URL="http://${PULSE_DEV_API_HOST}:${PULSE_DEV_API_PORT}"
@@ -220,6 +220,13 @@ pkill -x "pulse" 2>/dev/null || true
sleep 1
pkill -9 -x "pulse" 2>/dev/null || true
+# Kill any stale OpenCode sidecar processes
+# These accumulate when Pulse restarts without proper cleanup
+log_info "Cleaning up stale OpenCode processes..."
+pkill -f "opencode.*serve" 2>/dev/null || true
+sleep 1
+pkill -9 -f "opencode.*serve" 2>/dev/null || true
+
kill_port "${FRONTEND_DEV_PORT}"
kill_port "${PULSE_DEV_API_PORT}"
kill_port "${EXTRA_CLEANUP_PORT}"
@@ -407,7 +414,7 @@ log_info "Starting backend health monitor..."
PULSE_DEV=${PULSE_DEV:-true} \
PULSE_AUTH_USER=${PULSE_AUTH_USER} \
PULSE_AUTH_PASS=${PULSE_AUTH_PASS} \
- ./pulse &
+ ./pulse >> /opt/pulse/hotdev.log 2>&1 &
NEW_PID=$!
sleep 2
if kill -0 "$NEW_PID" 2>/dev/null; then
@@ -444,7 +451,11 @@ log_info "Starting backend file watcher..."
fi
fi
- FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} PULSE_DATA_DIR=${PULSE_DATA_DIR} PULSE_USE_OPENCODE=${PULSE_USE_OPENCODE:-true} ALLOW_ADMIN_BYPASS=${ALLOW_ADMIN_BYPASS:-1} PULSE_DEV=${PULSE_DEV:-true} ./pulse &
+ # Kill OpenCode sidecar - Pulse will spawn a fresh one
+ # This prevents stale sidecars with lost session context
+ pkill -f "opencode.*serve" 2>/dev/null || true
+
+ FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} PULSE_DATA_DIR=${PULSE_DATA_DIR} PULSE_USE_OPENCODE=${PULSE_USE_OPENCODE:-true} ALLOW_ADMIN_BYPASS=${ALLOW_ADMIN_BYPASS:-1} PULSE_DEV=${PULSE_DEV:-true} ./pulse >> /opt/pulse/hotdev.log 2>&1 &
NEW_PID=$!
sleep 1
@@ -517,6 +528,11 @@ cleanup() {
fi
fi
+ # Kill OpenCode sidecar (spawned by Pulse)
+ pkill -f "opencode.*serve" 2>/dev/null || true
+ sleep 1
+ pkill -9 -f "opencode.*serve" 2>/dev/null || true
+
# Kill Frontend (Vite)
if [[ -n ${VITE_PID:-} ]] && kill -0 "${VITE_PID}" 2>/dev/null; then
kill "${VITE_PID}" 2>/dev/null || true