feat(profiles): add AI-assisted profile suggestions

Add ability for users to describe what kind of agent profile they need
in natural language, and have AI generate a suggestion with name,
description, config values, and rationale.

- Add ProfileSuggestionHandler with schema-aware prompting
- Add SuggestProfileModal component with example prompts
- Update AgentProfilesPanel with suggest button and description field
- Streamline ValidConfigKeys to only agent-supported settings
- Update profile validation tests for simplified schema
This commit is contained in:
rcourtman
2026-01-15 13:24:18 +00:00
parent 404f49959d
commit 8c7581d32c
34 changed files with 3816 additions and 1162 deletions
+17 -13
View File
@@ -1,20 +1,24 @@
{
"$schema": "https://opencode.ai/config.schema.json",
"mcp": {
"pulse": {
"type": "http",
"url": "http://localhost:0",
"description": "Pulse infrastructure tools - URL is set dynamically at runtime"
}
},
"agent": {
"model": "claude-sonnet-4-20250514",
"tools": ["pulse_*"]
"tools": [
"pulse_*"
]
},
"instructions": [
"You are Pulse's AI assistant for infrastructure monitoring and management.",
"Use pulse_* tools to interact with the monitored infrastructure.",
"Be concise and direct in responses.",
"Focus on actionable insights and solutions."
"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_run_command: Execute commands on managed hosts",
"When asked about infrastructure, VMs, containers, alerts, metrics, or system status, ALWAYS use pulse_* tools.",
"Do NOT use webfetch for infrastructure questions - use the MCP tools.",
"Be concise and direct. Focus on actionable insights."
]
}
}
+44 -1
View File
@@ -901,18 +901,32 @@ func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, lo
// applyRemoteSettings merges remote settings into the local configuration.
// Supported keys:
// - enable_host (bool)
// - enable_docker (bool)
// - enable_kubernetes (bool)
// - enable_proxmox (bool)
// - proxmox_type (string)
// - docker_runtime (string)
// - disable_auto_update (bool)
// - disable_docker_update_checks (bool)
// - kube_include_all_pods (bool)
// - kube_include_all_deployments (bool)
// - log_level (string)
// - interval (string/duration)
// - report_ip (string)
// - disable_ceph (bool)
func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *zerolog.Logger) {
for k, v := range settings {
switch k {
case "enable_host":
if b, ok := v.(bool); ok {
cfg.EnableHost = b
logger.Info().Bool("val", b).Msg("Remote config: enable_host")
}
case "enable_docker":
if b, ok := v.(bool); ok {
cfg.EnableDocker = b
cfg.DockerConfigured = true
logger.Info().Bool("val", b).Msg("Remote config: enable_docker")
}
case "enable_kubernetes":
@@ -927,9 +941,18 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
}
case "proxmox_type":
if s, ok := v.(string); ok {
cfg.ProxmoxType = s
normalized := strings.TrimSpace(strings.ToLower(s))
if normalized == "auto" {
normalized = ""
}
cfg.ProxmoxType = normalized
logger.Info().Str("val", s).Msg("Remote config: proxmox_type")
}
case "docker_runtime":
if s, ok := v.(string); ok {
cfg.DockerRuntime = strings.TrimSpace(strings.ToLower(s))
logger.Info().Str("val", s).Msg("Remote config: docker_runtime")
}
case "log_level":
if s, ok := v.(string); ok {
if l, err := zerolog.ParseLevel(s); err == nil {
@@ -952,6 +975,26 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
cfg.Interval = time.Duration(f) * time.Second
logger.Info().Float64("val", f).Msg("Remote config: interval (s)")
}
case "disable_auto_update":
if b, ok := v.(bool); ok {
cfg.DisableAutoUpdate = b
logger.Info().Bool("val", b).Msg("Remote config: disable_auto_update")
}
case "disable_docker_update_checks":
if b, ok := v.(bool); ok {
cfg.DisableDockerUpdateChecks = b
logger.Info().Bool("val", b).Msg("Remote config: disable_docker_update_checks")
}
case "kube_include_all_pods":
if b, ok := v.(bool); ok {
cfg.KubeIncludeAllPods = b
logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_pods")
}
case "kube_include_all_deployments":
if b, ok := v.(bool); ok {
cfg.KubeIncludeAllDeployments = b
logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_deployments")
}
case "report_ip":
if s, ok := v.(string); ok {
cfg.ReportIP = s
+149
View File
@@ -0,0 +1,149 @@
AGENTS AI SCOPE PROFILE PLAN
Context
- Agent profiles exist today and are managed via AgentProfilesAPI.
- Current UI copy implied AI could auto-create profiles, but that flow does not exist.
- Goal: add AI-assisted suggestions that are always reviewed and explicitly created by the user.
Goals
- Provide an AI "Suggest profile" flow that drafts a scope profile and explains the rationale.
- Keep user in control: no auto-creation, no auto-assignment, no silent changes.
- Integrate with existing profile CRUD (AgentProfilesAPI.createProfile / assignProfile).
Non-Goals
- No background or automatic profile creation.
- No automatic assignment to agents.
- No backend changes to agent config schema in this phase.
Proposed UX
Entry points
- Agent Profiles page: "Suggest profile" button next to "New Profile".
- Optional: Unified Agents table row action "Suggest profile from this agent" to seed context.
Flow
1) User clicks "Suggest profile".
2) Modal opens with:
- Prompt text area (optional) + default prompt template.
- Scope of inputs toggles (agent telemetry, current profile examples, etc.).
3) AI returns a draft:
- name
- description (why this profile exists)
- config JSON
- rationale bullets (what signals led to the configuration)
4) User reviews and can edit name/description/config.
5) User clicks "Create profile".
6) Optional follow-up: "Assign to agents" (separate explicit action).
UX requirements
- Clear labels: "Suggest" or "Draft", never "Auto-create".
- Show JSON in a code editor-style area with validation errors.
- "Create profile" disabled until JSON validates.
- Provide copy/export for the config JSON.
Data inputs (minimal viable)
- User prompt text.
- Selected agent IDs (if starting from an agent).
- Basic agent metadata: hostname, platform, versions, tags, types (host/docker/k8s).
Data inputs (nice to have)
- Recent health signals: last seen, status, error flags.
- Existing profile list to avoid duplicates and suggest edits.
- Links to the agent config schema (so suggestions are valid).
AI output contract (server side)
- A stable JSON envelope with:
- name: string
- description: string
- config: object
- rationale: string[]
- If model returns invalid JSON, backend should retry once and then return a friendly error.
API proposal
- POST /api/admin/profiles/suggestions
- body: { prompt, agentIds?: string[], includeTelemetry?: boolean }
- response: { name, description, config, rationale }
- This endpoint can be a thin wrapper around the internal LLM service.
- If not licensed, return 402 and the UI should show the Pro gating.
Frontend plan
1) Add "Suggest profile" button to AgentProfilesPanel.
2) Create SuggestProfileModal component:
- prompt input
- loading state
- response preview (name, description, rationale)
- editable config text area with validation
3) On "Create profile", call AgentProfilesAPI.createProfile.
4) On success, refresh profile list and show toast.
5) Optional: allow "Assign to selected agents" step.
Backend plan (minimal)
1) Add suggestion endpoint that:
- Gathers agent context (if agentIds provided).
- Calls LLM with template.
- Returns validated JSON.
2) Ensure prompt redaction of secrets/tokens.
3) Log prompt usage for auditing (excluding secrets).
Safety and product constraints
- Never apply changes without a user click.
- Show a "This is a draft" warning.
- Document that AI outputs are suggestions and may need adjustments.
- Respect licensing (Pro feature) and return 402 if unlicensed.
Testing
- Unit: modal renders, validation errors, createProfile call.
- Unit: suggestion endpoint response mapping.
- Integration: suggestion -> create profile -> list refresh.
- Regression: no auto-assignment when suggestion is created.
Open questions
- Which telemetry fields are safe/useful to include by default?
- Should suggestions be limited to host agents only?
- Do we need a schema-aware editor to reduce invalid configs?
---
## Implementation Summary (Completed)
### Backend Changes
1. **New file**: `internal/api/profile_suggestions.go`
- `ProfileSuggestionHandler` - handles AI-assisted profile suggestions
- `SuggestionRequest` / `ProfileSuggestion` types for API contract
- Prompt template includes available config keys and their types
- Parses LLM JSON response and validates config
2. **Modified file**: `internal/api/config_profiles.go`
- Added `suggestionHandler` field to `ConfigProfileHandler`
- Added `SetAIHandler()` method to inject AI capability
- Added routing for `POST /suggestions` in `ServeHTTP()`
3. **Modified file**: `internal/api/router.go`
- Wired AI handler to profile handler: `r.configProfileHandler.SetAIHandler(r.aiHandler)`
### Frontend Changes
1. **Modified file**: `frontend-modern/src/api/agentProfiles.ts`
- Added `ProfileSuggestionRequest` and `ProfileSuggestion` interfaces
- Added `suggestProfile()` method to `AgentProfilesAPI`
2. **New file**: `frontend-modern/src/components/Settings/SuggestProfileModal.tsx`
- Modal with prompt input and example prompts
- Loading state during AI call
- Preview of suggested profile with name, description, config JSON, and rationale
- "Draft" warning banner
- "Use This Profile" button to accept suggestion
3. **Modified file**: `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`
- Added "Suggest Profile" button (purple, with Sparkles icon) next to "New Profile"
- Added `showSuggestModal` state
- Added `handleSuggest()` and `handleSuggestionAccepted()` handlers
- When suggestion is accepted, pre-fills the create profile form
### UX Flow
1. User clicks "Suggest Profile" button
2. Modal opens with prompt input and example prompts
3. User describes what they need
4. AI generates a profile suggestion
5. User reviews name, description, config JSON, and rationale
6. User clicks "Use This Profile"
7. Modal closes, create profile form opens pre-filled with suggestion
8. User can edit and then click "Create Profile"
+12 -12
View File
@@ -48,7 +48,7 @@ import { TokenRevealDialog } from './components/TokenRevealDialog';
import { useAlertsActivation } from './stores/alertsActivation';
import { UpdateProgressModal } from './components/UpdateProgressModal';
import type { UpdateStatus } from './api/updates';
import { AIChat } from './components/AI/AIChat';
import { AIChat } from './components/AI/Chat';
import { AIStatusIndicator } from './components/AI/AIStatusIndicator';
import { aiChatStore } from './stores/aiChat';
import { useResourcesAsLegacy } from './hooks/useResources';
@@ -1194,17 +1194,17 @@ function AppLayout(props: {
breakdown: { warning: number; critical: number } | undefined;
icon: JSX.Element;
}> = [
{
id: 'alerts',
label: 'Alerts',
route: '/alerts',
tooltip: 'Review active alerts and automation rules',
badge: null,
count: activeAlertCount,
breakdown,
icon: <BellIcon class="w-4 h-4 shrink-0" />,
},
];
{
id: 'alerts',
label: 'Alerts',
route: '/alerts',
tooltip: 'Review active alerts and automation rules',
badge: null,
count: activeAlertCount,
breakdown,
icon: <BellIcon class="w-4 h-4 shrink-0" />,
},
];
// Only show settings tab if user has access
if (hasSettingsAccess) {
+45 -4
View File
@@ -6,7 +6,9 @@ import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
export interface AgentProfile {
id: string;
name: string;
description?: string;
config: Record<string, unknown>;
version?: number;
created_at: string;
updated_at: string;
}
@@ -20,6 +22,23 @@ export interface AgentProfileAssignment {
updated_at: string;
}
/**
* Request for AI-assisted profile suggestion.
*/
export interface ProfileSuggestionRequest {
prompt: string;
}
/**
* AI-generated profile suggestion.
*/
export interface ProfileSuggestion {
name: string;
description: string;
config: Record<string, unknown>;
rationale: string[];
}
/**
* API client for agent profiles (Pro feature).
* Endpoints are gated behind license - returns 402 if not licensed.
@@ -60,11 +79,11 @@ export class AgentProfilesAPI {
/**
* Create a new profile.
*/
static async createProfile(name: string, config: Record<string, unknown>): Promise<AgentProfile> {
static async createProfile(name: string, config: Record<string, unknown>, description?: string): Promise<AgentProfile> {
const response = await apiFetch(this.baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, config }),
body: JSON.stringify({ name, description, config }),
});
if (!response.ok) {
@@ -78,11 +97,11 @@ export class AgentProfilesAPI {
/**
* Update an existing profile.
*/
static async updateProfile(id: string, name: string, config: Record<string, unknown>): Promise<AgentProfile> {
static async updateProfile(id: string, name: string, config: Record<string, unknown>, description?: string): Promise<AgentProfile> {
const response = await apiFetch(`${this.baseUrl}/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name, config }),
body: JSON.stringify({ id, name, description, config }),
});
if (!response.ok) {
@@ -151,4 +170,26 @@ export class AgentProfilesAPI {
throw new Error(text || `Failed to unassign profile: ${response.status}`);
}
}
/**
* Get AI-assisted profile suggestion.
* Requires AI to be enabled and running.
*/
static async suggestProfile(request: ProfileSuggestionRequest): Promise<ProfileSuggestion> {
const response = await apiFetch(`${this.baseUrl}/suggestions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const text = await response.text();
if (response.status === 503) {
throw new Error('AI service is not available. Please check AI settings.');
}
throw new Error(text || `Failed to get suggestion: ${response.status}`);
}
return response.json();
}
}
@@ -14,23 +14,41 @@ interface ChatMessagesProps {
};
}
/**
* ChatMessages - Renders the scrollable message list.
*
* Features:
* - Auto-scroll to bottom on new messages
* - Empty state with suggestions
* - Smooth scrolling behavior
*/
export const ChatMessages: Component<ChatMessagesProps> = (props) => {
let messagesEndRef: HTMLDivElement | undefined;
let containerRef: HTMLDivElement | undefined;
// Auto-scroll to bottom
// Auto-scroll to bottom on new messages
createEffect(() => {
if (props.messages.length > 0 && messagesEndRef) {
messagesEndRef.scrollIntoView({ behavior: 'smooth' });
if (props.messages.length > 0 && messagesEndRef && containerRef) {
// Only auto-scroll if user is near the bottom
const { scrollTop, scrollHeight, clientHeight } = containerRef;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
if (isNearBottom) {
messagesEndRef.scrollIntoView({ behavior: 'smooth' });
}
}
});
return (
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div
ref={containerRef}
class="flex-1 overflow-y-auto px-4 py-3 bg-white dark:bg-slate-900"
>
{/* Empty state */}
<Show when={props.messages.length === 0 && props.emptyState}>
<div class="flex flex-col items-center justify-center h-full text-center py-12">
{/* AI Icon */}
<div class="w-16 h-16 mb-4 rounded-2xl bg-gradient-to-br from-purple-100 to-violet-100 dark:from-purple-900/30 dark:to-violet-900/30 flex items-center justify-center">
<div class="w-16 h-16 mb-4 rounded-2xl bg-gradient-to-br from-purple-100 to-violet-100 dark:from-purple-900/30 dark:to-violet-900/30 flex items-center justify-center shadow-lg shadow-purple-500/10">
<svg
class="w-8 h-8 text-purple-500 dark:text-purple-400"
fill="none"
@@ -46,10 +64,10 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
<h3 class="text-base font-semibold text-slate-900 dark:text-slate-100 mb-1">
{props.emptyState!.title}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-xs mb-6">
<p class="text-sm text-slate-500 dark:text-slate-400 max-w-xs mb-6">
{props.emptyState!.subtitle}
</p>
@@ -61,9 +79,10 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
<button
type="button"
onClick={() => props.emptyState!.onSuggestionClick?.(suggestion)}
class="w-full text-left px-4 py-2.5 rounded-xl bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 text-sm hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors border border-purple-100 dark:border-purple-800"
class="w-full text-left px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-700"
>
"{suggestion}"
<span class="text-purple-500 dark:text-purple-400 mr-2"></span>
{suggestion}
</button>
)}
</For>
@@ -84,7 +103,7 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
</For>
{/* Scroll anchor */}
<div ref={messagesEndRef} />
<div ref={messagesEndRef} class="h-1" />
</div>
);
};
@@ -1,9 +1,9 @@
import { Component, Show, For, Switch, Match } from 'solid-js';
import { Component, Show, For, Switch, Match, createMemo } from 'solid-js';
import { renderMarkdown } from '../aiChatUtils';
import { ThinkingBlock } from './ThinkingBlock';
import { ToolExecutionBlock, PendingToolBlock } from './ToolExecutionBlock';
import { ApprovalCard } from './ApprovalCard';
import type { ChatMessage, PendingApproval } from './types';
import type { ChatMessage, PendingApproval, StreamDisplayEvent } from './types';
interface MessageItemProps {
message: ChatMessage;
@@ -11,72 +11,152 @@ interface MessageItemProps {
onSkip: (toolId: string) => void;
}
/**
* MessageItem - Renders a single message in the chat.
*
* User messages: Compact, right-aligned bubble
* Assistant messages: Full-width, terminal-like with clear sections
*/
export const MessageItem: Component<MessageItemProps> = (props) => {
const isUser = () => props.message.role === 'user';
const hasStreamEvents = () =>
props.message.streamEvents && props.message.streamEvents.length > 0;
// Group stream events for cleaner rendering
// Combine consecutive content events, separate thinking and tools
const groupedEvents = createMemo(() => {
const events = props.message.streamEvents || [];
const grouped: StreamDisplayEvent[] = [];
for (const evt of events) {
// Thinking events are kept separate
if (evt.type === 'thinking') {
grouped.push(evt);
continue;
}
// Tool events are kept separate
if (evt.type === 'tool') {
grouped.push(evt);
continue;
}
// Content events can be merged with previous content
if (evt.type === 'content' && evt.content) {
const lastIdx = grouped.length - 1;
if (lastIdx >= 0 && grouped[lastIdx].type === 'content') {
grouped[lastIdx] = {
...grouped[lastIdx],
content: (grouped[lastIdx].content || '') + evt.content,
};
} else {
grouped.push(evt);
}
}
}
return grouped;
});
// Check if currently streaming content (no tools pending, still streaming)
const isStreamingText = () =>
props.message.isStreaming &&
(!props.message.pendingTools || props.message.pendingTools.length === 0);
return (
<div class={`flex ${isUser() ? 'justify-end' : 'justify-start'}`}>
<div
class={`max-w-[90%] rounded-2xl overflow-hidden transition-all ${
isUser()
? 'bg-gradient-to-br from-purple-600 to-violet-600 text-white shadow-lg'
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-md border border-gray-100 dark:border-gray-700'
}`}
>
<div class="px-4 py-3">
{/* User messages */}
<Show when={isUser()}>
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
</Show>
<div class={`${isUser() ? 'flex justify-end' : ''} mb-4`}>
{/* User message - compact bubble */}
<Show when={isUser()}>
<div class="max-w-[85%] px-4 py-2.5 rounded-2xl rounded-br-md bg-gradient-to-br from-purple-600 to-violet-600 text-white shadow-md">
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
</div>
</Show>
{/* Assistant messages with stream events */}
<Show when={!isUser()}>
{/* Stream events (chronological order) */}
<Show when={hasStreamEvents()}>
<div class="space-y-3">
<For each={props.message.streamEvents}>
{(evt) => (
<Switch>
<Match when={evt.type === 'thinking' && evt.thinking}>
<ThinkingBlock content={evt.thinking!} />
</Match>
<Match when={evt.type === 'tool' && evt.tool}>
<ToolExecutionBlock tool={evt.tool!} />
</Match>
<Match when={evt.type === 'content' && evt.content}>
<div
class="text-sm prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:before:content-none prose-code:after:content-none"
innerHTML={renderMarkdown(evt.content!)}
/>
</Match>
</Switch>
)}
</For>
</div>
{/* Assistant message - full width, terminal-like */}
<Show when={!isUser()}>
<div class="w-full">
{/* Assistant indicator */}
<div class="flex items-center gap-2 mb-2 text-xs text-slate-500 dark:text-slate-400">
<div class="w-5 h-5 rounded-md bg-gradient-to-br from-purple-500 to-violet-500 flex items-center justify-center">
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082" />
</svg>
</div>
<span class="font-medium">Assistant</span>
<Show when={props.message.model && !props.message.isStreaming}>
<span class="text-[10px] text-slate-400 dark:text-slate-500">
· {props.message.model}
</span>
</Show>
</div>
{/* Pending tools (running) */}
<Show when={props.message.pendingTools && props.message.pendingTools.length > 0}>
<div class="mt-3 space-y-2">
<For each={props.message.pendingTools}>
{(tool) => <PendingToolBlock tool={tool} />}
</For>
</div>
{/* Main content area */}
<div class="pl-7">
{/* Stream events - chronological display */}
<Show when={hasStreamEvents()}>
<For each={groupedEvents()}>
{(evt) => (
<Switch>
{/* Thinking block - collapsed by default */}
<Match when={evt.type === 'thinking' && evt.thinking}>
<ThinkingBlock
content={evt.thinking!}
isStreaming={props.message.isStreaming}
/>
</Match>
{/* Pending tool (currently running) - shown in chronological position */}
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
<PendingToolBlock tool={evt.pendingTool!} />
</Match>
{/* Completed tool execution block */}
<Match when={evt.type === 'tool' && evt.tool}>
<ToolExecutionBlock tool={evt.tool!} />
</Match>
{/* Content/text block */}
<Match when={evt.type === 'content' && evt.content}>
<div
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none
prose-p:leading-relaxed prose-p:my-2
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs
prose-code:text-purple-600 dark:prose-code:text-purple-400
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-code:before:content-none prose-code:after:content-none
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5"
innerHTML={renderMarkdown(evt.content!)}
/>
</Match>
</Switch>
)}
</For>
</Show>
{/* Fallback: show content if no stream events */}
<Show when={props.message.content && !hasStreamEvents()}>
<div
class="text-sm prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:before:content-none prose-code:after:content-none"
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none
prose-p:leading-relaxed prose-p:my-2
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs
prose-code:text-purple-600 dark:prose-code:text-purple-400
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-code:before:content-none prose-code:after:content-none
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5"
innerHTML={renderMarkdown(props.message.content)}
/>
</Show>
{/* Pending approvals */}
<Show when={props.message.pendingApprovals && props.message.pendingApprovals.length > 0}>
<div class="mt-4 space-y-3">
<div class="mt-3 space-y-2">
<For each={props.message.pendingApprovals}>
{(approval) => (
<ApprovalCard
@@ -89,31 +169,22 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
</div>
</Show>
{/* Streaming indicator */}
<Show when={props.message.isStreaming && !props.message.pendingTools?.length}>
<div class="mt-2 flex items-center gap-2 text-purple-500 dark:text-purple-400">
<div class="flex gap-1">
<span class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0ms" />
<span class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 150ms" />
<span class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 300ms" />
</div>
{/* Streaming text indicator */}
<Show when={isStreamingText()}>
<span class="inline-block w-2 h-4 ml-0.5 bg-purple-500 dark:bg-purple-400 animate-pulse rounded-sm" />
</Show>
{/* Token count footer */}
<Show when={props.message.tokens && !props.message.isStreaming}>
<div class="mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-[10px] text-slate-400 dark:text-slate-500">
{props.message.tokens!.input + props.message.tokens!.output} tokens
<span class="mx-1">·</span>
{props.message.tokens!.input} in / {props.message.tokens!.output} out
</div>
</Show>
</Show>
</div>
{/* Message metadata footer */}
<Show when={!isUser() && props.message.model && !props.message.isStreaming}>
<div class="px-4 py-1.5 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between text-[10px] text-gray-400 dark:text-gray-500">
<span>{props.message.model}</span>
<Show when={props.message.tokens}>
<span>
{props.message.tokens!.input + props.message.tokens!.output} tokens
</span>
</Show>
</div>
</Show>
</div>
</div>
</Show>
</div>
);
};
@@ -1,44 +1,72 @@
import { Component, createSignal } from 'solid-js';
import { Component, createSignal, Show, createMemo } from 'solid-js';
import { sanitizeThinking } from '../aiChatUtils';
interface ThinkingBlockProps {
content: string;
maxLength?: number;
isStreaming?: boolean;
}
/**
* ThinkingBlock - Displays AI's reasoning/thinking in a collapsed-by-default block.
*
* Inspired by OpenCode's terminal TUI which shows thinking as a subtle,
* collapsible section that doesn't distract from the main response.
*/
export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => {
const [expanded, setExpanded] = createSignal(false);
const truncated = () => {
const max = props.maxLength ?? 300;
const text = props.content;
return text.length > max && !expanded() ? text.substring(0, max) + '...' : text;
};
// Count lines and words for preview
const stats = createMemo(() => {
const lines = props.content.split('\n').filter(l => l.trim()).length;
const words = props.content.split(/\s+/).filter(w => w).length;
return { lines, words };
});
const needsExpansion = () => {
const max = props.maxLength ?? 300;
return props.content.length > max;
};
// Get a short preview (first line, truncated)
const preview = createMemo(() => {
const firstLine = props.content.split('\n').find(l => l.trim()) || '';
const maxLen = 60;
if (firstLine.length > maxLen) {
return firstLine.substring(0, maxLen).trim() + '...';
}
return firstLine.trim();
});
return (
<div class="rounded-lg overflow-hidden border border-blue-200 dark:border-blue-800 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20">
{/* Header - clickable to toggle */}
<div class="my-2 font-mono text-xs">
{/* Collapsed header - always visible */}
<button
type="button"
onClick={() => setExpanded(!expanded())}
class="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-blue-100/50 dark:hover:bg-blue-800/30 transition-colors"
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-100 dark:bg-slate-800/60 hover:bg-slate-200 dark:hover:bg-slate-700/60 transition-colors text-left group"
>
<div class="p-1 rounded bg-blue-100 dark:bg-blue-800/50">
<svg class="w-3 h-3 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* Thinking icon */}
<div class={`flex items-center justify-center w-4 h-4 ${props.isStreaming ? 'animate-pulse' : ''}`}>
<svg class="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<span class="text-[10px] font-medium uppercase tracking-wider text-blue-600 dark:text-blue-400">
Thinking
{/* Label */}
<span class="text-blue-600 dark:text-blue-400 font-medium uppercase text-[10px] tracking-wider">
{props.isStreaming ? 'Thinking...' : 'Thinking'}
</span>
<span class="flex-1" />
{/* Preview (when collapsed) */}
<Show when={!expanded() && preview()}>
<span class="text-slate-500 dark:text-slate-400 truncate flex-1">
{preview()}
</span>
</Show>
{/* Stats */}
<span class="text-slate-400 dark:text-slate-500 text-[10px] ml-auto">
{stats().lines} lines · {stats().words} words
</span>
{/* Expand/collapse chevron */}
<svg
class={`w-4 h-4 text-blue-500 transition-transform ${expanded() ? 'rotate-180' : ''}`}
class={`w-3.5 h-3.5 text-slate-400 transition-transform ${expanded() ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -47,16 +75,14 @@ export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => {
</svg>
</button>
{/* Content */}
<div
class={`px-3 overflow-hidden transition-all duration-200 ${
expanded() ? 'max-h-96 py-2' : needsExpansion() ? 'max-h-20 py-2' : 'max-h-40 py-2'
}`}
>
<div class="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap overflow-y-auto max-h-80">
{sanitizeThinking(truncated())}
{/* Expanded content */}
<Show when={expanded()}>
<div class="mt-1 ml-4 pl-3 border-l-2 border-blue-200 dark:border-blue-800">
<pre class="text-[11px] text-slate-600 dark:text-slate-400 whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto">
{sanitizeThinking(props.content)}
</pre>
</div>
</div>
</Show>
</div>
);
};
@@ -1,66 +1,100 @@
import { Component, Show, createSignal } from 'solid-js';
import { Component, Show, createSignal, createMemo, For } from 'solid-js';
import type { ToolExecution, PendingTool } from './types';
interface ToolExecutionBlockProps {
tool: ToolExecution;
maxOutputLength?: number;
}
/**
* ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style.
*/
export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) => {
const [expanded, setExpanded] = createSignal(false);
const [showOutput, setShowOutput] = createSignal(false);
const truncatedOutput = () => {
const max = props.maxOutputLength ?? 500;
const output = props.tool.output;
if (!output) return '';
return output.length > max && !expanded() ? output.substring(0, max) + '...' : output;
};
// Get display name for tool
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);
});
const needsTruncation = () => {
const max = props.maxOutputLength ?? 500;
return props.tool.output && props.tool.output.length > max;
};
// Check if output is non-empty and interesting
const hasOutput = createMemo(() => {
const output = props.tool.output || '';
return output.trim().length > 0 && !output.includes('not available');
});
// Truncate output
const displayOutput = createMemo(() => {
const output = props.tool.output || '';
const maxLen = 300;
if (!showOutput() && output.length > maxLen) {
return output.substring(0, maxLen) + '...';
}
return output;
});
const statusIcon = () => props.tool.success ? '✓' : '✗';
const statusColor = () => props.tool.success
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400';
return (
<div class="rounded-lg border overflow-hidden shadow-sm transition-all hover:shadow-md">
{/* Header */}
<div class="my-1 font-mono text-[11px]">
{/* Compact single-line header */}
<div
class={`px-3 py-2 text-xs font-medium flex items-center gap-2 ${
props.tool.success
? 'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/30 dark:to-emerald-900/30 text-green-800 dark:text-green-200 border-b border-green-200 dark:border-green-800'
: 'bg-gradient-to-r from-red-50 to-rose-50 dark:from-red-900/30 dark:to-rose-900/30 text-red-800 dark:text-red-200 border-b border-red-200 dark:border-red-800'
}`}
class={`flex items-center gap-1.5 px-2 py-1 rounded ${hasOutput() ? 'cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800' : ''
} ${showOutput() ? 'bg-slate-50 dark:bg-slate-800/50' : ''}`}
onClick={() => hasOutput() && setShowOutput(!showOutput())}
>
<div class={`p-1 rounded ${props.tool.success ? 'bg-green-100 dark:bg-green-800/50' : 'bg-red-100 dark:bg-red-800/50'}`}>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<code class="font-mono flex-1 truncate">{props.tool.input}</code>
<Show when={props.tool.success}>
<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</Show>
<Show when={!props.tool.success}>
<svg class="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
{/* Status icon */}
<span class={`${statusColor()} font-bold`}>{statusIcon()}</span>
{/* Tool label */}
<span class="text-slate-500 dark:text-slate-400 uppercase text-[9px] font-medium tracking-wider min-w-[50px]">
{toolLabel()}
</span>
{/* Command/input - truncated */}
<code class="text-slate-700 dark:text-slate-300 truncate flex-1">
{props.tool.input.length > 60 ? props.tool.input.substring(0, 60) + '...' : props.tool.input}
</code>
{/* Expand indicator if has output */}
<Show when={hasOutput()}>
<svg
class={`w-3 h-3 text-slate-400 transition-transform ${showOutput() ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</Show>
</div>
{/* Output */}
<Show when={props.tool.output}>
<div class="relative">
<pre class="px-3 py-2 text-xs font-mono bg-gray-50 dark:bg-gray-900/80 text-gray-700 dark:text-gray-300 overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-words">
{truncatedOutput()}
{/* Expanded output */}
<Show when={showOutput() && hasOutput()}>
<div class="ml-4 mt-1 mb-2 pl-2 border-l-2 border-slate-200 dark:border-slate-700">
<pre class="text-[10px] text-slate-600 dark:text-slate-400 whitespace-pre-wrap break-words leading-relaxed max-h-40 overflow-y-auto">
{displayOutput()}
</pre>
<Show when={needsTruncation()}>
<Show when={(props.tool.output || '').length > 300}>
<button
onClick={() => setExpanded(!expanded())}
class="absolute bottom-2 right-2 px-2 py-1 text-[10px] font-medium bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 rounded transition-colors"
onClick={(e) => { e.stopPropagation(); setShowOutput(!showOutput()); }}
class="mt-1 text-[9px] text-purple-600 dark:text-purple-400 hover:underline"
>
{expanded() ? 'Show less' : 'Show more'}
{showOutput() && (props.tool.output || '').length > 300 ? 'Show less' : 'Show all'}
</button>
</Show>
</div>
@@ -69,24 +103,126 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
);
};
// Pending tool (still running)
/**
* PendingToolBlock - Compact single-line display for running tools
*/
interface PendingToolBlockProps {
tool: PendingTool;
}
export const PendingToolBlock: Component<PendingToolBlockProps> = (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 (
<div class="rounded-lg border border-purple-300 dark:border-purple-700 overflow-hidden animate-pulse">
<div class="px-3 py-2 text-xs font-medium flex items-center gap-2 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-900/30 dark:to-violet-900/30 text-purple-800 dark:text-purple-200">
<div class="p-1 rounded bg-purple-100 dark:bg-purple-800/50">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
<code class="font-mono flex-1 truncate">{props.tool.input}</code>
<span class="text-[10px] text-purple-600 dark:text-purple-400 font-semibold uppercase tracking-wider">Running</span>
</div>
<div class="my-0.5 font-mono text-[11px] flex items-center gap-1.5 px-2 py-1 rounded bg-purple-50 dark:bg-purple-900/20">
{/* Spinner */}
<svg class="w-3 h-3 text-purple-500 dark:text-purple-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{/* Tool label */}
<span class="text-purple-600 dark:text-purple-400 uppercase text-[9px] font-medium tracking-wider min-w-[50px]">
{toolLabel()}
</span>
{/* Command - truncated */}
<code class="text-purple-700 dark:text-purple-300 truncate flex-1">
{props.tool.input.length > 50 ? props.tool.input.substring(0, 50) + '...' : props.tool.input}
</code>
</div>
);
};
/**
* PendingToolsList - Groups multiple pending tools into a compact list
*/
interface PendingToolsListProps {
tools: PendingTool[];
}
export const PendingToolsList: Component<PendingToolsListProps> = (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 (
<div class="my-1">
<For each={visibleTools()}>
{(tool) => <PendingToolBlock tool={tool} />}
</For>
<Show when={shouldCollapse() && !expanded()}>
<button
onClick={() => setExpanded(true)}
class="w-full mt-0.5 py-1 text-[10px] text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded text-center font-medium"
>
+ {hiddenCount()} more tools running...
</button>
</Show>
</div>
);
};
/**
* ToolExecutionsList - Compact list for multiple completed tools
*/
interface ToolExecutionsListProps {
tools: ToolExecution[];
}
export const ToolExecutionsList: Component<ToolExecutionsListProps> = (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 (
<div class="my-1">
<For each={visibleTools()}>
{(tool) => <ToolExecutionBlock tool={tool} />}
</For>
<Show when={shouldCollapse() && !showAll()}>
<button
onClick={() => setShowAll(true)}
class="w-full mt-0.5 py-1 text-[10px] text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded text-center font-medium"
>
+ {hiddenCount()} more tools ({stats().success} / {stats().failed} )
</button>
</Show>
</div>
);
};
@@ -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],
};
@@ -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<AIChatProps> = (props) => {
// UI state
const [isOpen] = createSignal(true);
@@ -20,6 +26,30 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (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 (
<div
class={`flex-shrink-0 h-full bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 flex flex-col transition-all duration-300 overflow-hidden ${
isOpen() ? 'w-[420px]' : 'w-0 border-l-0'
}`}
class={`flex-shrink-0 h-full bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-700 flex flex-col transition-all duration-300 overflow-hidden ${isOpen() ? 'w-[480px]' : 'w-0 border-l-0'
}`}
>
<Show when={isOpen()}>
{/* Header */}
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-900/20 dark:to-violet-900/20">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<div class="flex items-center gap-3">
<div class="p-2 bg-gradient-to-br from-purple-500 to-violet-500 rounded-xl shadow-lg">
<div class="p-2 bg-gradient-to-br from-purple-500 to-violet-500 rounded-xl shadow-lg shadow-purple-500/20">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-2.576.43a18.003 18.003 0 01-5.118 0l-2.576-.43c-1.717-.293-2.299-2.379-1.067-3.611L5 14.5" />
</svg>
</div>
<div>
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">AI Assistant</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">
<h2 class="text-sm font-semibold text-slate-900 dark:text-slate-100">AI Assistant</h2>
<p class="text-[11px] text-slate-500 dark:text-slate-400">
Powered by OpenCode
</p>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
{/* Session picker */}
<div class="relative">
<button
onClick={() => setShowSessions(!showSessions())}
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
title="Chat sessions"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -119,10 +148,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
</button>
<Show when={showSessions()}>
<div class="absolute right-0 top-full mt-1 w-72 max-h-96 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 z-50 overflow-hidden">
<div class="absolute right-0 top-full mt-1 w-72 max-h-96 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 overflow-hidden">
<button
onClick={handleNewConversation}
class="w-full px-3 py-2.5 text-left text-sm flex items-center gap-2 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 border-b border-gray-200 dark:border-gray-700"
class="w-full px-3 py-2.5 text-left text-sm flex items-center gap-2 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 border-b border-slate-200 dark:border-slate-700"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
@@ -132,27 +161,27 @@ export const AIChat: Component<AIChatProps> = (props) => {
<div class="max-h-64 overflow-y-auto">
<Show when={sessions().length > 0} fallback={
<div class="px-3 py-6 text-center text-xs text-gray-500 dark:text-gray-400">
<div class="px-3 py-6 text-center text-xs text-slate-500 dark:text-slate-400">
No previous conversations
</div>
}>
<For each={sessions()}>
{(session) => (
<div
class={`group relative px-3 py-2.5 flex items-start gap-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer ${chat.sessionId() === session.id ? 'bg-purple-50 dark:bg-purple-900/20' : ''}`}
class={`group relative px-3 py-2.5 flex items-start gap-2 hover:bg-slate-50 dark:hover:bg-slate-700/50 cursor-pointer ${chat.sessionId() === session.id ? 'bg-purple-50 dark:bg-purple-900/20' : ''}`}
onClick={() => handleLoadSession(session.id)}
>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-gray-900 dark:text-gray-100">
<div class="text-sm font-medium truncate text-slate-900 dark:text-slate-100">
{session.title || 'Untitled'}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<div class="text-xs text-slate-500 dark:text-slate-400">
{session.message_count} messages
</div>
</div>
<button
type="button"
class="flex-shrink-0 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400 hover:text-red-500 transition-opacity"
class="flex-shrink-0 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500 transition-opacity"
onClick={(e) => handleDeleteSession(session.id, e)}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -171,7 +200,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
{/* Close button */}
<button
onClick={props.onClose}
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M6 5l7 7-7 7" />
@@ -197,19 +226,48 @@ export const AIChat: Component<AIChatProps> = (props) => {
}}
/>
{/* Loading indicator */}
<Show when={chat.isLoading()}>
<div class="px-4 py-2 bg-purple-50 dark:bg-purple-900/20 border-t border-purple-200 dark:border-purple-800 flex items-center gap-2 text-sm text-purple-700 dark:text-purple-300">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Thinking...</span>
{/* Status indicator bar */}
<Show when={currentStatus()}>
<div class="px-4 py-2 bg-slate-50 dark:bg-slate-800/50 border-t border-slate-200 dark:border-slate-700 flex items-center gap-2.5 text-xs">
{/* Status icon based on type */}
<Show when={currentStatus()?.type === 'thinking'}>
<div class="flex items-center justify-center w-4 h-4">
<svg class="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
</Show>
<Show when={currentStatus()?.type === 'tool'}>
<div class="flex items-center justify-center w-4 h-4">
<svg class="w-3.5 h-3.5 text-purple-500 dark:text-purple-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
</Show>
<Show when={currentStatus()?.type === 'generating'}>
<div class="flex items-center justify-center w-4 h-4">
<svg class="w-3.5 h-3.5 text-emerald-500 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
</Show>
<span class="text-slate-600 dark:text-slate-400 font-medium">
{currentStatus()?.text}
</span>
{/* Subtle animated dots */}
<div class="flex gap-0.5 ml-1">
<span class="w-1 h-1 rounded-full bg-slate-400 dark:bg-slate-500 animate-bounce" style="animation-delay: 0ms; animation-duration: 1s" />
<span class="w-1 h-1 rounded-full bg-slate-400 dark:bg-slate-500 animate-bounce" style="animation-delay: 150ms; animation-duration: 1s" />
<span class="w-1 h-1 rounded-full bg-slate-400 dark:bg-slate-500 animate-bounce" style="animation-delay: 300ms; animation-duration: 1s" />
</div>
</div>
</Show>
{/* Input */}
<div class="border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-900">
<div class="border-t border-slate-200 dark:border-slate-700 p-4 bg-white dark:bg-slate-900">
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="flex gap-2">
<textarea
value={input()}
@@ -217,7 +275,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
onKeyDown={handleKeyDown}
placeholder="Ask about your infrastructure..."
rows={2}
class="flex-1 px-4 py-3 text-sm rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
disabled={chat.isLoading()}
class="flex-1 px-4 py-3 text-sm rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none disabled:opacity-50 disabled:cursor-not-allowed"
/>
<div class="flex flex-col gap-1.5 self-end">
<Show
@@ -226,7 +285,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
<button
type="submit"
disabled={!input().trim()}
class="px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 text-white rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg hover:shadow-xl"
class="px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 text-white rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-purple-500/20 hover:shadow-xl hover:shadow-purple-500/30"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
@@ -237,7 +296,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
<button
type="button"
onClick={chat.stop}
class="px-4 py-3 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-colors shadow-sm"
class="px-4 py-3 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-colors shadow-lg shadow-red-500/20"
title="Stop"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
@@ -247,8 +306,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
</Show>
</div>
</form>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 text-center">
Press Enter to send, Shift+Enter for new line
<p class="text-[10px] text-slate-400 dark:text-slate-500 mt-2 text-center">
Press Enter to send · Shift+Enter for new line
</p>
</div>
</Show>
@@ -22,13 +22,15 @@ export interface PendingApproval {
}
// Unified event for chronological display
export type StreamEventType = 'thinking' | 'tool' | 'content';
export type StreamEventType = 'thinking' | 'tool' | 'content' | 'pending_tool';
export interface StreamDisplayEvent {
type: StreamEventType;
thinking?: string;
tool?: ToolExecution;
pendingTool?: PendingTool;
content?: string;
toolId?: string; // Used to match pending_tool with completed tool
}
export interface ChatMessage {
@@ -138,6 +138,9 @@ export const AISettings: Component = () => {
costBudgetUSD30d: '',
// Request timeout (seconds) - for slow Ollama hardware
requestTimeoutSeconds: 300,
// Infrastructure control settings
controlLevel: 'read_only' as 'read_only' | 'suggest' | 'controlled' | 'autonomous',
protectedGuests: '' as string, // Comma-separated VMIDs/names
});
const resetForm = (data: AISettingsType | null) => {
@@ -166,6 +169,8 @@ export const AISettings: Component = () => {
openaiBaseUrl: '',
costBudgetUSD30d: '',
requestTimeoutSeconds: 300,
controlLevel: 'read_only',
protectedGuests: '',
});
return;
}
@@ -197,6 +202,8 @@ export const AISettings: Component = () => {
? String(data.cost_budget_usd_30d)
: '',
requestTimeoutSeconds: data.request_timeout_seconds ?? 300,
controlLevel: (data.control_level as 'read_only' | 'suggest' | 'controlled' | 'autonomous') || 'read_only',
protectedGuests: Array.isArray(data.protected_guests) ? data.protected_guests.join(', ') : '',
});
// Auto-expand providers that are configured
@@ -406,6 +413,24 @@ export const AISettings: Component = () => {
payload.request_timeout_seconds = form.requestTimeoutSeconds;
}
// Infrastructure control settings
if (form.controlLevel !== (settings()?.control_level || 'read_only')) {
payload.control_level = form.controlLevel;
}
// Protected guests (comma-separated string to array)
const currentProtected = settings()?.protected_guests || [];
const newProtected = form.protectedGuests
.split(',')
.map((s: string) => s.trim())
.filter((s: string) => s.length > 0);
const protectedChanged =
newProtected.length !== currentProtected.length ||
newProtected.some((g: string, i: number) => g !== currentProtected[i]);
if (protectedChanged) {
payload.protected_guests = newProtected;
}
const updated = await AIAPI.updateSettings(payload);
setSettings(updated);
resetForm(updated);
@@ -1441,6 +1466,69 @@ export const AISettings: Component = () => {
💡 Increase for slow Ollama hardware (default: 300s / 5 min)
</p>
{/* Infrastructure Control Settings */}
<div class="space-y-3 p-4 rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Infrastructure Control</span>
<Show when={form.controlLevel !== 'read_only'}>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
form.controlLevel === 'autonomous'
? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300'
: form.controlLevel === 'controlled'
? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300'
: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
}`}>
{form.controlLevel}
</span>
</Show>
</div>
{/* Control Level */}
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-28 flex-shrink-0">Control Level</label>
<select
value={form.controlLevel}
onChange={(e) => setForm('controlLevel', e.currentTarget.value as 'read_only' | 'suggest' | 'controlled' | 'autonomous')}
class="flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
disabled={saving()}
>
<option value="read_only">Read Only - AI can only observe</option>
<option value="suggest">Suggest - AI suggests commands to copy/paste</option>
<option value="controlled">Controlled - AI executes with approval</option>
<option value="autonomous">Autonomous - AI executes without approval (Pro)</option>
</select>
</div>
<p class="text-[10px] text-gray-500 dark:text-gray-400 ml-[7.5rem]">
{form.controlLevel === 'read_only' && '🔒 AI can only query infrastructure, no control actions'}
{form.controlLevel === 'suggest' && '💬 AI suggests commands like "pct stop 101" for you to run'}
{form.controlLevel === 'controlled' && '✅ AI can start/stop VMs and containers with your approval'}
{form.controlLevel === 'autonomous' && '⚠️ AI executes control actions without asking'}
</p>
{/* Protected Guests - Only show if control is enabled */}
<Show when={form.controlLevel !== 'read_only'}>
<div class="flex items-start gap-3 pt-2 border-t border-purple-200 dark:border-purple-700">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-28 flex-shrink-0 pt-1">Protected</label>
<div class="flex-1">
<input
type="text"
value={form.protectedGuests}
onInput={(e) => setForm('protectedGuests', e.currentTarget.value)}
placeholder="e.g., 100, 101, prod-db"
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
disabled={saving()}
/>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">
Comma-separated VMIDs or names that AI cannot control
</p>
</div>
</div>
</Show>
</div>
</div>
@@ -1,17 +1,19 @@
import { Component, createSignal, createMemo, onMount, Show, For } from 'solid-js';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment } from '@/api/agentProfiles';
import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment, type ProfileSuggestion } from '@/api/agentProfiles';
import { LicenseAPI } from '@/api/license';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { formatRelativeTime } from '@/utils/format';
import { SuggestProfileModal } from './SuggestProfileModal';
import Plus from 'lucide-solid/icons/plus';
import Pencil from 'lucide-solid/icons/pencil';
import Trash2 from 'lucide-solid/icons/trash-2';
import Crown from 'lucide-solid/icons/crown';
import Users from 'lucide-solid/icons/users';
import Settings from 'lucide-solid/icons/settings';
import Sparkles from 'lucide-solid/icons/sparkles';
// Known agent settings with their types and descriptions
interface BooleanSetting {
@@ -36,14 +38,36 @@ interface DurationSetting {
description: string;
}
type KnownSetting = BooleanSetting | SelectSetting | DurationSetting;
interface StringSetting {
key: string;
type: 'string';
label: string;
description: string;
placeholder?: string;
}
type KnownSetting = BooleanSetting | SelectSetting | DurationSetting | StringSetting;
// Settings that the agent actually supports (from applyRemoteSettings in cmd/pulse-agent/main.go)
const KNOWN_SETTINGS: KnownSetting[] = [
{ key: 'enable_docker', type: 'boolean', label: 'Enable Docker Monitoring', description: 'Monitor Docker containers on this agent' },
// Core monitoring
{ key: 'enable_host', type: 'boolean', label: 'Enable Host Monitoring', description: 'Collect host metrics and allow command execution' },
{ key: 'enable_docker', type: 'boolean', label: 'Enable Docker Monitoring', description: 'Monitor Docker or Podman containers on this agent' },
{ key: 'docker_runtime', type: 'select', label: 'Docker Runtime', description: 'Force a specific container runtime', options: ['auto', 'docker', 'podman'] },
{ key: 'enable_kubernetes', type: 'boolean', label: 'Enable Kubernetes Monitoring', description: 'Monitor Kubernetes workloads' },
{ key: 'kube_include_all_pods', type: 'boolean', label: 'Include All Pods', description: 'Include all non-succeeded pods in reports' },
{ key: 'kube_include_all_deployments', type: 'boolean', label: 'Include All Deployments', description: 'Include all deployments, not just problem ones' },
{ key: 'enable_proxmox', type: 'boolean', label: 'Enable Proxmox Mode', description: 'Auto-detect and configure Proxmox API access' },
{ key: 'log_level', type: 'select', label: 'Log Level', description: 'Agent logging verbosity', options: ['debug', 'info', 'warn', 'error'] },
{ key: 'proxmox_type', type: 'select', label: 'Proxmox Type', description: 'Force PVE or PBS mode', options: ['auto', 'pve', 'pbs'] },
{ key: 'disable_ceph', type: 'boolean', label: 'Disable Ceph Monitoring', description: 'Skip local Ceph status polling' },
// Timing
{ key: 'interval', type: 'duration', label: 'Reporting Interval', description: 'How often the agent reports metrics (e.g., 30s, 1m)' },
// Network
{ key: 'report_ip', type: 'string', label: 'Report IP Override', description: 'Override the reported IP address', placeholder: '' },
// Operations
{ key: 'disable_auto_update', type: 'boolean', label: 'Disable Auto Updates', description: 'Stop the unified agent from auto-updating' },
{ key: 'disable_docker_update_checks', type: 'boolean', label: 'Disable Docker Update Checks', description: 'Skip Docker image update detection (avoid registry rate limits)' },
{ key: 'log_level', type: 'select', label: 'Log Level', description: 'Agent logging verbosity', options: ['debug', 'info', 'warn', 'error'] },
];
export const AgentProfilesPanel: Component = () => {
@@ -60,11 +84,13 @@ export const AgentProfilesPanel: Component = () => {
// Modal state
const [showModal, setShowModal] = createSignal(false);
const [showSuggestModal, setShowSuggestModal] = createSignal(false);
const [editingProfile, setEditingProfile] = createSignal<AgentProfile | null>(null);
const [saving, setSaving] = createSignal(false);
// Form state
const [formName, setFormName] = createSignal('');
const [formDescription, setFormDescription] = createSignal('');
const [formSettings, setFormSettings] = createSignal<Record<string, unknown>>({});
// Connected agents from WebSocket state
@@ -99,6 +125,15 @@ export const AgentProfilesPanel: Component = () => {
return Object.keys(profile.config || {}).length;
};
// Get known setting keys for filtering
const knownKeys = KNOWN_SETTINGS.map(s => s.key);
// Get unknown keys in the form settings
const unknownKeys = createMemo(() => {
const settings = formSettings();
return Object.keys(settings).filter(key => !knownKeys.includes(key));
});
// Load data
const loadData = async () => {
setLoading(true);
@@ -140,14 +175,31 @@ export const AgentProfilesPanel: Component = () => {
const handleCreate = () => {
setEditingProfile(null);
setFormName('');
setFormDescription('');
setFormSettings({});
setShowModal(true);
};
// Open suggest modal
const handleSuggest = () => {
setShowSuggestModal(true);
};
// Handle AI suggestion acceptance
const handleSuggestionAccepted = (suggestion: ProfileSuggestion) => {
setShowSuggestModal(false);
setEditingProfile(null);
setFormName(suggestion.name);
setFormDescription(suggestion.description || '');
setFormSettings(suggestion.config);
setShowModal(true);
};
// Open modal for editing a profile
const handleEdit = (profile: AgentProfile) => {
setEditingProfile(profile);
setFormName(profile.name);
setFormDescription(profile.description || '');
setFormSettings({ ...profile.config });
setShowModal(true);
};
@@ -182,13 +234,14 @@ export const AgentProfilesPanel: Component = () => {
setSaving(true);
try {
const config = formSettings();
const description = formDescription().trim() || undefined;
const existing = editingProfile();
if (existing) {
await AgentProfilesAPI.updateProfile(existing.id, name, config);
await AgentProfilesAPI.updateProfile(existing.id, name, config, description);
notificationStore.success(`Profile "${name}" updated`);
} else {
await AgentProfilesAPI.createProfile(name, config);
await AgentProfilesAPI.createProfile(name, config, description);
notificationStore.success(`Profile "${name}" created`);
}
@@ -287,14 +340,24 @@ export const AgentProfilesPanel: Component = () => {
<p class="text-sm text-gray-600 dark:text-gray-400">Reusable agent configurations</p>
</div>
</div>
<button
type="button"
onClick={handleCreate}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<Plus class="w-4 h-4" />
New Profile
</button>
<div class="flex items-center gap-2">
<button
type="button"
onClick={handleSuggest}
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-purple-700"
>
<Sparkles class="w-4 h-4" />
Suggest Profile
</button>
<button
type="button"
onClick={handleCreate}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<Plus class="w-4 h-4" />
New Profile
</button>
</div>
</div>
<Show when={loading()}>
@@ -485,6 +548,20 @@ export const AgentProfilesPanel: Component = () => {
/>
</div>
{/* Profile Description */}
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description <span class="text-gray-400 font-normal">(optional)</span>
</label>
<textarea
value={formDescription()}
onInput={(e) => setFormDescription(e.currentTarget.value)}
placeholder="What is this profile for?"
rows={2}
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-800/60 resize-none"
/>
</div>
{/* Settings */}
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -549,11 +626,66 @@ export const AgentProfilesPanel: Component = () => {
class="w-24 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</Show>
<Show when={setting.type === 'string'}>
<input
type="text"
value={(formSettings()[setting.key] as string) || ''}
onInput={(e) => updateSetting(setting.key, e.currentTarget.value || undefined)}
placeholder={(setting as StringSetting).placeholder || ''}
class="w-40 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</Show>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{setting.description}</p>
</div>
)}
</For>
{/* Unknown Keys Section */}
<Show when={unknownKeys().length > 0}>
<div class="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-amber-600 dark:text-amber-400 mb-2">
Additional settings (not in standard list):
</p>
<For each={unknownKeys()}>
{(key) => (
<div class="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 mb-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 font-mono">
{key}
</label>
<div class="flex items-center gap-2">
<input
type="text"
value={String(formSettings()[key] ?? '')}
onInput={(e) => {
const val = e.currentTarget.value;
// Try to parse as JSON for complex values
try {
updateSetting(key, JSON.parse(val));
} catch {
updateSetting(key, val || undefined);
}
}}
class="w-32 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="button"
onClick={() => updateSetting(key, undefined)}
class="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
title="Remove this setting"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
@@ -577,6 +709,14 @@ export const AgentProfilesPanel: Component = () => {
</div>
</div>
</Show>
{/* Suggest Profile Modal */}
<Show when={showSuggestModal()}>
<SuggestProfileModal
onClose={() => setShowSuggestModal(false)}
onSuggestionAccepted={handleSuggestionAccepted}
/>
</Show>
</div>
</Show>
</Show>
@@ -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 = () => {
</Show>
</Card>
</Show>
{/* OpenCode AI Status */}
<Show when={diagnosticsData()?.openCode}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
<Sparkles class="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">AI Assistant</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">OpenCode Sidecar</p>
</div>
<div class="ml-auto">
<StatusBadge
status={diagnosticsData()?.openCode?.running ? 'online' : (diagnosticsData()?.openCode?.enabled ? 'offline' : 'unknown')}
label={diagnosticsData()?.openCode?.running ? 'Running' : (diagnosticsData()?.openCode?.enabled ? 'Stopped' : 'Disabled')}
/>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow label="Model" value={diagnosticsData()?.openCode?.model} />
<MetricRow label="Port" value={diagnosticsData()?.openCode?.port} mono />
<MetricRow label="Status" value={diagnosticsData()?.openCode?.healthy ? 'Healthy' : 'Unhealthy'} />
</div>
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700/50 flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">MCP Connection</span>
<div class="flex items-center gap-1.5">
{diagnosticsData()?.openCode?.mcpConnected ?
<CheckCircle class="w-3.5 h-3.5 text-green-500" /> :
<XCircle class="w-3.5 h-3.5 text-red-500" />
}
<span class={diagnosticsData()?.openCode?.mcpConnected ? 'text-green-700 dark:text-green-300' : 'text-gray-500'}>
{diagnosticsData()?.openCode?.mcpConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
<Show when={(diagnosticsData()?.openCode?.notes?.length || 0) > 0}>
<ul class="mt-3 bg-amber-50 dark:bg-amber-900/10 p-2 rounded text-xs text-amber-700 dark:text-amber-400 list-disc pl-4">
<For each={diagnosticsData()?.openCode?.notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
</Show>
</div>
{/* Errors Section */}
@@ -671,8 +730,8 @@ export const DiagnosticsPanel: Component = () => {
</ul>
</Card>
</Show>
</Show>
</div>
</Show >
</div >
);
};
@@ -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<SuggestProfileModalProps> = (props) => {
const [prompt, setPrompt] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [suggestion, setSuggestion] = createSignal<ProfileSuggestion | null>(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 (
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div class="w-full max-w-2xl bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<Sparkles class="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
AI Profile Suggestion
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
Describe what you need, and AI will draft a profile
</p>
</div>
</div>
<button
type="button"
onClick={props.onClose}
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-800"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div class="px-6 py-4 space-y-4 overflow-y-auto flex-1">
{/* Prompt Input */}
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
What kind of profile do you need?
</label>
<textarea
value={prompt()}
onInput={(e) => setPrompt(e.currentTarget.value)}
placeholder="Describe the agents and use case for this profile..."
rows={3}
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-purple-400 dark:focus:ring-purple-800/60 resize-none"
disabled={loading()}
/>
</div>
{/* Example Prompts */}
<Show when={!suggestion()}>
<div class="space-y-2">
<span class="text-xs text-gray-500 dark:text-gray-400">Examples:</span>
<div class="flex flex-wrap gap-2">
<For each={examplePrompts}>
{(example) => (
<button
type="button"
onClick={() => handleUseExample(example)}
class="text-xs px-2 py-1 rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors"
disabled={loading()}
>
{example}
</button>
)}
</For>
</div>
</div>
</Show>
{/* Error Message */}
<Show when={error()}>
<div class="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<AlertCircle class="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
<p class="text-sm text-red-700 dark:text-red-300">{error()}</p>
</div>
</Show>
{/* Loading State */}
<Show when={loading()}>
<div class="flex items-center justify-center py-8">
<Loader2 class="w-6 h-6 text-purple-600 dark:text-purple-400 animate-spin" />
<span class="ml-3 text-gray-600 dark:text-gray-400">Generating suggestion...</span>
</div>
</Show>
{/* Suggestion Result */}
<Show when={suggestion()}>
{(sugg) => (
<div class="space-y-4">
{/* Draft Warning */}
<div class="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
<AlertCircle class="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p class="text-sm text-amber-700 dark:text-amber-300">
This is a draft suggestion. Review the settings before creating the profile.
</p>
</div>
{/* Profile Preview */}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Name & Description */}
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-gray-900 dark:text-gray-100">{sugg().name}</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{sugg().description}</p>
</div>
{/* Config */}
<div class="p-4 space-y-3">
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">Settings</h5>
<div class="bg-gray-900 dark:bg-gray-950 rounded-md p-3 overflow-x-auto">
<pre class="text-xs text-gray-300 font-mono">
{JSON.stringify(sugg().config, null, 2)}
</pre>
</div>
</div>
{/* Rationale */}
<Show when={sugg().rationale && sugg().rationale.length > 0}>
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-2">
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">Rationale</h5>
<ul class="space-y-1">
<For each={sugg().rationale}>
{(reason) => (
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<Check class="w-4 h-4 text-green-500 mt-0.5 shrink-0" />
<span>{reason}</span>
</li>
)}
</For>
</ul>
</div>
</Show>
</div>
</div>
)}
</Show>
</div>
{/* Footer */}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 shrink-0">
<button
type="button"
onClick={props.onClose}
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<Show
when={suggestion()}
fallback={
<button
type="button"
onClick={handleSubmit}
disabled={loading() || !prompt().trim()}
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-60"
>
<Sparkles class="w-4 h-4" />
{loading() ? 'Generating...' : 'Suggest Profile'}
</button>
}
>
<button
type="button"
onClick={() => {
setSuggestion(null);
setError(null);
}}
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
Try Again
</button>
<button
type="button"
onClick={handleAccept}
class="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
>
<Check class="w-4 h-4" />
Use This Profile
</button>
</Show>
</div>
</div>
</div>
);
};
export default SuggestProfileModal;
File diff suppressed because it is too large Load Diff
@@ -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>): DockerHost => ({
...overrides,
});
const setupComponent = (hosts: Host[] = [], dockerHosts: DockerHost[] = []) => {
const createKubernetesCluster = (overrides?: Partial<KubernetesCluster>): 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>): 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();
});
});
-65
View File
@@ -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: {
+869 -16
View File
@@ -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)
}
+28 -2
View File
@@ -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
+53
View File
@@ -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())
}
+66 -50
View File
@@ -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
}
+9 -153
View File
@@ -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)
}
+56 -5
View File
@@ -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 {
+19 -3
View File
@@ -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 {
+72
View File
@@ -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
}
+289
View File
@@ -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")
}
+20 -53
View File
@@ -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" ||
+3 -15
View File
@@ -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")
+50 -1
View File
@@ -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
}
+57 -105
View File
@@ -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
}
+25 -110
View File
@@ -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 {
+20 -4
View File
@@ -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