feat: Add AgentBar arc dock with per-agent terminal frame and voice modal

- Add ui.json configs for Main (purple) and Ejecutor (red) agents
- AgentBar: fused arc-shaped dock at bottom with dynamic glow
- Quick press opens styled terminal frame mockup
- Hold opens voice modal with Web Speech API streaming transcription
- Responsive: full-width mobile, max-width on tablet/desktop/4K
- Agents API: serve uiConfig from ui.json in agent directories
- Agents page: route, store, toolbar integration
This commit is contained in:
2026-02-15 02:58:11 -06:00
parent 9f9f335439
commit e9689d6ea8
12 changed files with 1698 additions and 31 deletions

View File

@@ -5,6 +5,6 @@
"repo": "anthropics/claude-plugins-official" "repo": "anthropics/claude-plugins-official"
}, },
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official", "installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
"lastUpdated": "2026-02-15T04:49:09.493Z" "lastUpdated": "2026-02-15T08:27:07.485Z"
} }
} }

9
.claude-ejecutor/ui.json Normal file
View File

@@ -0,0 +1,9 @@
{
"label": "Ejecutor",
"shortLabel": "EJ",
"color": "#ef4444",
"gradient": "linear-gradient(135deg, #ef4444, #dc2626)",
"terminalBg": "#0a0f1a",
"terminalBorder": "#ef4444",
"enabled": true
}

View File

@@ -79,7 +79,10 @@
"mcp__agent-ui__z590_nucleoriofrio_com-save_vue_component", "mcp__agent-ui__z590_nucleoriofrio_com-save_vue_component",
"mcp__agent-ui__z590_nucleoriofrio_com-resize_window", "mcp__agent-ui__z590_nucleoriofrio_com-resize_window",
"mcp__agent-ui__z590_nucleoriofrio_com-save_canvas_snapshot", "mcp__agent-ui__z590_nucleoriofrio_com-save_canvas_snapshot",
"mcp__agent-ui__z590_nucleoriofrio_com-load_canvas_snapshot" "mcp__agent-ui__z590_nucleoriofrio_com-load_canvas_snapshot",
"mcp__agent-ui__z590_nucleoriofrio_com-list_canvas_snapshots",
"mcp__agent-ui__z590_nucleoriofrio_com-list_canvases",
"mcp__agent-ui__z590_nucleoriofrio_com-list_vue_components"
] ]
}, },
"enableAllProjectMcpServers": true, "enableAllProjectMcpServers": true,
@@ -153,30 +156,6 @@
] ]
} }
], ],
"SubagentStart": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"subagentStart\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
"timeout": 5000
}
]
}
],
"SubagentStop": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"subagentStop\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
"timeout": 5000
}
]
}
],
"PermissionRequest": [ "PermissionRequest": [
{ {
"matcher": ".*", "matcher": ".*",

9
.claude/ui.json Normal file
View File

@@ -0,0 +1,9 @@
{
"label": "Main",
"shortLabel": "M",
"color": "#6366f1",
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
"terminalBg": "#0f0a1a",
"terminalBorder": "#6366f1",
"enabled": true
}

View File

@@ -0,0 +1,720 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
// Web Speech API types
interface SpeechRecognitionEvent extends Event {
resultIndex: number
results: SpeechRecognitionResultList
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
}
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
onend: (() => void) | null
start(): void
stop(): void
abort(): void
}
interface UiConfig {
label: string
shortLabel: string
color: string
gradient: string
terminalBg: string
terminalBorder: string
enabled: boolean
}
interface Agent {
id: string
name: string
directory: string
uiConfig: UiConfig | null
}
const agents = ref<Agent[]>([])
const loading = ref(true)
// Terminal frame state
const terminalAgent = ref<Agent | null>(null)
const showTerminal = ref(false)
// Voice modal state
const voiceAgent = ref<Agent | null>(null)
const showVoice = ref(false)
const isRecording = ref(false)
const transcript = ref('')
const interimTranscript = ref('')
let recognition: SpeechRecognition | null = null
// Hold detection
let holdTimer: number | null = null
let holdTriggered = false
const HOLD_THRESHOLD = 400
const enabledAgents = computed(() =>
agents.value.filter(a => a.uiConfig?.enabled)
)
// Dynamic glow based on agent colors
const barGlowStyle = computed(() => {
const list = enabledAgents.value
if (!list.length) return {}
const glows = list.map(a => {
const c = a.uiConfig?.color || '#6366f1'
return `0 -6px 30px ${c}50, 0 -2px 15px ${c}30`
})
return {
boxShadow: `${glows.join(', ')}, 0 -10px 50px rgba(0,0,0,0.4)`
}
})
async function fetchAgents() {
try {
const res = await fetch('/api/agents')
if (!res.ok) return
const data: Agent[] = await res.json()
agents.value = data
} catch {
// silent
} finally {
loading.value = false
}
}
// --- Terminal Frame ---
function openTerminal(agent: Agent) {
terminalAgent.value = agent
showTerminal.value = true
}
function closeTerminal() {
showTerminal.value = false
terminalAgent.value = null
}
// --- Voice Modal ---
function openVoice(agent: Agent) {
voiceAgent.value = agent
showVoice.value = true
startRecognition()
}
function closeVoice() {
stopRecognition()
showVoice.value = false
voiceAgent.value = null
transcript.value = ''
interimTranscript.value = ''
}
function initSpeechRecognition(): SpeechRecognition | null {
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SR) return null
const rec = new SR()
rec.continuous = true
rec.interimResults = true
rec.lang = 'es-419'
rec.onresult = (event: SpeechRecognitionEvent) => {
let interim = ''
let final = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (!result || !result[0]) continue
if (result.isFinal) {
final += result[0].transcript + ' '
} else {
interim += result[0].transcript
}
}
if (final) transcript.value += final
interimTranscript.value = interim
}
rec.onerror = () => {
isRecording.value = false
}
rec.onend = () => {
if (isRecording.value) {
try { rec.start() } catch { /* ignore */ }
}
}
return rec
}
function startRecognition() {
if (!recognition) recognition = initSpeechRecognition()
if (!recognition) return
transcript.value = ''
interimTranscript.value = ''
try {
recognition.start()
isRecording.value = true
} catch { /* ignore */ }
}
function stopRecognition() {
if (recognition) {
isRecording.value = false
try { recognition.stop() } catch { /* ignore */ }
}
}
// --- Pill interaction ---
function handlePointerDown(agent: Agent) {
holdTriggered = false
holdTimer = window.setTimeout(() => {
holdTriggered = true
openVoice(agent)
}, HOLD_THRESHOLD)
}
function handlePointerUp(agent: Agent) {
if (holdTimer) {
clearTimeout(holdTimer)
holdTimer = null
}
if (!holdTriggered) {
openTerminal(agent)
}
}
function handlePointerLeave() {
if (holdTimer) {
clearTimeout(holdTimer)
holdTimer = null
}
}
// Responsive
const isMobile = ref(window.innerWidth < 768)
function onResize() {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
fetchAgents()
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
stopRecognition()
recognition = null
})
</script>
<template>
<!-- Agent Bar arc dock -->
<div v-if="enabledAgents.length" class="agent-bar">
<div class="agent-bar-inner" :style="barGlowStyle">
<button
v-for="agent in enabledAgents"
:key="agent.id"
class="agent-pill"
:style="{ background: agent.uiConfig?.gradient || agent.uiConfig?.color }"
@pointerdown.prevent="handlePointerDown(agent)"
@pointerup="handlePointerUp(agent)"
@pointerleave="handlePointerLeave"
@contextmenu.prevent
>
<span class="pill-label">
{{ isMobile ? agent.uiConfig?.shortLabel : agent.uiConfig?.label }}
</span>
</button>
</div>
</div>
<!-- Terminal Frame Modal -->
<Teleport to="body">
<Transition name="frame-fade">
<div v-if="showTerminal && terminalAgent" class="terminal-overlay" @click.self="closeTerminal">
<div
class="terminal-frame"
:style="{
'--term-bg': terminalAgent.uiConfig?.terminalBg || '#0f0a1a',
'--term-border': terminalAgent.uiConfig?.terminalBorder || '#6366f1',
'--term-color': terminalAgent.uiConfig?.color || '#6366f1'
}"
>
<div class="term-titlebar">
<div class="term-title-left">
<span class="term-dot red"></span>
<span class="term-dot yellow"></span>
<span class="term-dot green"></span>
<span class="term-name">{{ terminalAgent.uiConfig?.label || terminalAgent.name }}</span>
</div>
<button class="term-close" @click="closeTerminal">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="term-body">
<div class="term-cursor-line">
<span class="term-prompt" :style="{ color: terminalAgent.uiConfig?.color }">~$</span>
<span class="term-cursor">_</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Voice Modal -->
<Teleport to="body">
<Transition name="voice-fade">
<div v-if="showVoice && voiceAgent" class="voice-overlay" @click.self="closeVoice">
<div
class="voice-frame"
:style="{
'--voice-color': voiceAgent.uiConfig?.color || '#6366f1',
'--voice-gradient': voiceAgent.uiConfig?.gradient || 'linear-gradient(135deg, #6366f1, #8b5cf6)',
'--voice-bg': voiceAgent.uiConfig?.terminalBg || '#0f0a1a'
}"
>
<div class="voice-header">
<div class="voice-agent-badge" :style="{ background: voiceAgent.uiConfig?.gradient }">
{{ voiceAgent.uiConfig?.shortLabel }}
</div>
<span class="voice-title">Voice</span>
<div class="voice-rec-indicator" :class="{ active: isRecording }"></div>
<button class="voice-close" @click="closeVoice">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="voice-body">
<div class="voice-transcript" :class="{ empty: !transcript && !interimTranscript }">
<span class="voice-final">{{ transcript }}</span>
<span class="voice-interim">{{ interimTranscript }}</span>
<span v-if="!transcript && !interimTranscript" class="voice-placeholder">
Listening...
</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ====================== AGENT BAR — ARC DOCK ====================== */
.agent-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9990;
display: flex;
justify-content: center;
pointer-events: none;
}
.agent-bar-inner {
pointer-events: auto;
display: flex;
width: 100%;
height: 50px;
overflow: hidden;
/* Arc: wide elliptical curve at top, flat bottom */
border-radius: 50% 50% 0 0 / 22px 22px 0 0;
/* Top edge highlight */
border-top: 1px solid rgba(255, 255, 255, 0.15);
position: relative;
}
/* Sheen line following the arc */
.agent-bar-inner::after {
content: '';
position: absolute;
top: 0;
left: 15%;
right: 15%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
pointer-events: none;
z-index: 2;
}
.agent-pill {
flex: 1;
border: none;
color: #fff;
font-weight: 700;
font-size: 14px;
letter-spacing: 0.5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
transition: filter 0.15s ease, brightness 0.15s ease;
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
-webkit-touch-callout: none;
position: relative;
/* Safe area for phones with gesture bar */
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Divider between pills */
.agent-pill + .agent-pill {
border-left: 1px solid rgba(255, 255, 255, 0.18);
}
.agent-pill:hover {
filter: brightness(1.15);
}
.agent-pill:active {
filter: brightness(0.9);
}
.pill-label {
line-height: 1;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* ---- Responsive: Tablet (768+) ---- */
@media (min-width: 768px) {
.agent-bar-inner {
max-width: 480px;
height: 56px;
border-radius: 50% 50% 0 0 / 26px 26px 0 0;
}
.agent-pill {
font-size: 15px;
}
}
/* ---- Responsive: Desktop (1200+) ---- */
@media (min-width: 1200px) {
.agent-bar-inner {
max-width: 580px;
height: 62px;
border-radius: 50% 50% 0 0 / 30px 30px 0 0;
}
.agent-pill {
font-size: 16px;
letter-spacing: 0.8px;
}
}
/* ---- Responsive: 4K / TV (2400+) ---- */
@media (min-width: 2400px) {
.agent-bar-inner {
max-width: 740px;
height: 76px;
border-radius: 50% 50% 0 0 / 38px 38px 0 0;
}
.agent-pill {
font-size: 20px;
letter-spacing: 1px;
}
}
/* ====================== TERMINAL FRAME ====================== */
.terminal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10050;
}
.terminal-frame {
width: 640px;
max-width: 92vw;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--term-border);
box-shadow:
0 0 30px color-mix(in srgb, var(--term-border) 30%, transparent),
0 20px 60px rgba(0, 0, 0, 0.5);
}
.term-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 12px;
background: color-mix(in srgb, var(--term-bg) 80%, var(--term-border) 20%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.term-title-left {
display: flex;
align-items: center;
gap: 8px;
}
.term-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.term-dot.red { background: #ff5f57; }
.term-dot.yellow { background: #ffbd2e; }
.term-dot.green { background: #28c840; }
.term-name {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
margin-left: 4px;
font-family: system-ui, sans-serif;
}
.term-close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.term-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
.term-body {
min-height: 300px;
padding: 16px;
background: var(--term-bg);
font-family: 'Consolas', 'Monaco', 'Cascadia Code', monospace;
font-size: 14px;
}
.term-cursor-line {
display: flex;
align-items: center;
gap: 8px;
}
.term-prompt {
font-weight: 600;
}
.term-cursor {
color: rgba(255, 255, 255, 0.7);
animation: cursor-blink 1s step-end infinite;
}
@keyframes cursor-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* ====================== VOICE MODAL ====================== */
.voice-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10050;
}
.voice-frame {
width: 400px;
max-width: 90vw;
border-radius: 16px;
overflow: hidden;
background: var(--voice-bg);
border: 1px solid color-mix(in srgb, var(--voice-color) 40%, transparent);
box-shadow:
0 0 40px color-mix(in srgb, var(--voice-color) 25%, transparent),
0 20px 60px rgba(0, 0, 0, 0.5);
}
.voice-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: color-mix(in srgb, var(--voice-bg) 70%, var(--voice-color) 30%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.voice-agent-badge {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.voice-title {
flex: 1;
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
font-family: system-ui, sans-serif;
}
.voice-rec-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.2s;
}
.voice-rec-indicator.active {
background: #ef4444;
box-shadow: 0 0 8px #ef4444;
animation: rec-pulse 1s ease-in-out infinite;
}
@keyframes rec-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 8px #ef4444; }
50% { opacity: 0.5; box-shadow: 0 0 16px #ef4444; }
}
.voice-close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.voice-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
.voice-body {
min-height: 160px;
max-height: 300px;
padding: 20px;
overflow-y: auto;
}
.voice-transcript {
font-size: 16px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.9);
font-family: system-ui, sans-serif;
}
.voice-transcript.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
}
.voice-final {
color: rgba(255, 255, 255, 0.9);
}
.voice-interim {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
.voice-placeholder {
color: rgba(255, 255, 255, 0.25);
font-size: 15px;
font-style: italic;
animation: placeholder-pulse 2s ease-in-out infinite;
}
@keyframes placeholder-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ====================== TRANSITIONS ====================== */
.frame-fade-enter-active,
.voice-fade-enter-active {
transition: opacity 0.2s ease;
}
.frame-fade-leave-active,
.voice-fade-leave-active {
transition: opacity 0.15s ease;
}
.frame-fade-enter-from,
.frame-fade-leave-to,
.voice-fade-enter-from,
.voice-fade-leave-to {
opacity: 0;
}
.frame-fade-enter-active .terminal-frame {
animation: frame-scale-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.frame-fade-leave-active .terminal-frame {
animation: frame-scale-out 0.15s ease forwards;
}
.voice-fade-enter-active .voice-frame {
animation: frame-scale-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.voice-fade-leave-active .voice-frame {
animation: frame-scale-out 0.15s ease forwards;
}
@keyframes frame-scale-in {
from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes frame-scale-out {
from { transform: scale(1); opacity: 1; }
to { transform: scale(0.95); opacity: 0; }
}
</style>

View File

@@ -0,0 +1,488 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useAgentsStore, CATEGORY_META, formatSize } from '../stores/agents'
const store = useAgentsStore()
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
if (store.isDirty) store.saveFile()
}
}
function shortPath(path: string): string {
// Remove the agent directory prefix to show just the meaningful part
const parts = path.split('/')
if (parts.length <= 2) return parts[parts.length - 1]
return parts.slice(1).join('/')
}
onMounted(() => {
store.fetchAgents()
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<div class="agents-page">
<!-- Sidebar -->
<aside class="agents-sidebar">
<!-- Agent tabs -->
<div class="agent-tabs">
<button
v-for="agent in store.agents"
:key="agent.id"
class="agent-tab"
:class="{ active: store.selectedAgentId === agent.id }"
@click="store.selectAgent(agent.id)"
>
<span class="agent-tab-name">{{ agent.name }}</span>
<span class="agent-tab-count">{{ agent.files.length }}</span>
</button>
</div>
<!-- Loading -->
<div v-if="store.loading" class="sidebar-loading">Loading agents...</div>
<!-- Category groups -->
<div v-else-if="store.selectedAgent" class="category-list">
<div v-for="cat in store.groupedFiles" :key="cat.key" class="category-group">
<button class="category-header" @click="store.toggleCategory(cat.key)">
<svg
class="category-icon"
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="{ color: cat.color }"
>
<path v-for="(d, i) in cat.icon.split('|')" :key="i" :d="d"/>
</svg>
<span class="category-label">{{ cat.label }}</span>
<span class="category-count" :style="{ background: cat.color + '18', color: cat.color }">{{ cat.files.length }}</span>
<svg
class="category-chevron"
:class="{ collapsed: store.collapsedCategories.has(cat.key) }"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div v-if="!store.collapsedCategories.has(cat.key)" class="category-files">
<button
v-for="file in cat.files"
:key="file.path"
class="file-row"
:class="{ active: store.openFile?.path === file.path }"
@click="store.loadFile(store.selectedAgentId!, file)"
:title="file.path"
>
<span class="file-type-dot" :style="{ background: cat.color }"></span>
<span class="file-label">{{ shortPath(file.path) }}</span>
<span class="file-size">{{ formatSize(file.size) }}</span>
<span v-if="store.openFile?.path === file.path && store.isDirty" class="unsaved-dot"></span>
</button>
</div>
</div>
<div v-if="!store.groupedFiles.length" class="sidebar-empty">No files found</div>
</div>
</aside>
<!-- Editor -->
<main class="editor-panel">
<template v-if="store.openFile">
<div class="editor-header">
<div class="editor-title">
<span
class="editor-cat-dot"
:style="{ background: CATEGORY_META[store.openFile.category]?.color }"
></span>
<span class="editor-path">{{ store.openFile.path }}</span>
<span v-if="store.isDirty" class="unsaved-badge">Unsaved</span>
</div>
<div class="editor-actions">
<button
class="btn btn-secondary"
:disabled="!store.isDirty"
@click="store.revertFile()"
>Revert</button>
<button
class="btn btn-primary"
:disabled="!store.isDirty || store.saving"
@click="store.saveFile()"
>{{ store.saving ? 'Saving...' : 'Save' }}</button>
</div>
</div>
<div v-if="store.error" class="editor-error">{{ store.error }}</div>
<textarea
class="editor-textarea"
v-model="store.openFile.content"
spellcheck="false"
></textarea>
</template>
<div v-else class="editor-empty">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<p>Select a file to view or edit</p>
<span class="editor-hint">Ctrl+S to save</span>
</div>
</main>
</div>
</template>
<style scoped>
.agents-page {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Sidebar ── */
.agents-sidebar {
width: 320px;
min-width: 320px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Agent tabs */
.agent-tabs {
display: flex;
gap: 2px;
padding: 0.75rem 0.75rem 0;
overflow-x: auto;
flex-shrink: 0;
}
.agent-tab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.agent-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.agent-tab.active {
color: var(--text-primary);
background: var(--bg-primary);
border-color: var(--border-color);
font-weight: 600;
}
.agent-tab-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-tab-count {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: var(--bg-hover);
border-radius: 999px;
color: var(--text-muted);
}
.agent-tab.active .agent-tab-count {
background: rgba(99, 102, 241, 0.15);
color: #6366f1;
}
/* Category list */
.category-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
border-top: 1px solid var(--border-color);
}
.category-group {
margin-bottom: 2px;
}
.category-header {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.875rem;
background: none;
border: none;
color: var(--text-primary);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.category-header:hover {
background: var(--bg-hover);
}
.category-icon {
flex-shrink: 0;
}
.category-label {
flex: 1;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.6875rem;
}
.category-count {
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.4375rem;
border-radius: 999px;
}
.category-chevron {
color: var(--text-muted);
transition: transform 0.2s;
flex-shrink: 0;
}
.category-chevron.collapsed {
transform: rotate(-90deg);
}
/* File rows */
.category-files {
padding: 0 0 0.25rem;
}
.file-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3125rem 0.875rem 0.3125rem 2rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.12s;
text-align: left;
}
.file-row:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.file-row.active {
background: rgba(99, 102, 241, 0.08);
color: #6366f1;
}
.file-type-dot {
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
opacity: 0.6;
}
.file-row.active .file-type-dot {
opacity: 1;
}
.file-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.6875rem;
}
.file-size {
font-size: 0.625rem;
color: var(--text-muted);
flex-shrink: 0;
}
.unsaved-dot {
width: 6px;
height: 6px;
background: #f59e0b;
border-radius: 50%;
flex-shrink: 0;
}
.sidebar-loading,
.sidebar-empty {
padding: 2rem;
text-align: center;
color: var(--text-muted);
font-size: 0.8125rem;
}
/* ── Editor ── */
.editor-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-primary);
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
gap: 1rem;
}
.editor-title {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.editor-cat-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.editor-path {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
font-family: 'Consolas', 'Monaco', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.unsaved-badge {
padding: 0.125rem 0.5rem;
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
font-size: 0.6875rem;
font-weight: 600;
border-radius: 999px;
flex-shrink: 0;
}
.editor-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.btn {
padding: 0.3125rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.15s;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-primary {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.btn-primary:hover:not(:disabled) {
background: #4f46e5;
border-color: #4f46e5;
}
.editor-error {
padding: 0.625rem 1.25rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
font-size: 0.8125rem;
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
}
.editor-textarea {
flex: 1;
padding: 1rem 1.25rem;
background: var(--bg-primary);
color: var(--text-primary);
border: none;
outline: none;
resize: none;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.8125rem;
line-height: 1.6;
tab-size: 2;
}
.editor-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--text-muted);
}
.editor-empty p {
font-size: 0.875rem;
margin: 0;
}
.editor-hint {
font-size: 0.6875rem;
opacity: 0.5;
}
</style>

View File

@@ -53,6 +53,11 @@ const router = createRouter({
path: '/git', path: '/git',
name: 'git', name: 'git',
component: () => import('../pages/GitPage.vue') component: () => import('../pages/GitPage.vue')
},
{
path: '/agents',
name: 'agents',
component: () => import('../pages/AgentsPage.vue')
} }
] ]
}) })

View File

@@ -32,7 +32,7 @@ import { setRouter } from './tools/handlers/globalHandlers'
import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers' import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
import { ALL_TOOL_METAS, getAllToolNames, type ToolCategory } from './tools/toolDefinitions' import { ALL_TOOL_METAS, getAllToolNames, type ToolCategory } from './tools/toolDefinitions'
export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools' | 'git' export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools' | 'git' | 'agents'
// Internal webmcp functions (not exported for external use) // Internal webmcp functions (not exported for external use)
let webmcpInstance: any = null let webmcpInstance: any = null
@@ -164,7 +164,8 @@ const pageCategories: Record<PageName, ToolCategory[]> = {
source: ['global', 'torch', 'source', 'terminal'], source: ['global', 'torch', 'source', 'terminal'],
terminal: ['global', 'torch', 'terminal'], terminal: ['global', 'torch', 'terminal'],
tools: ['global', 'torch', 'terminal'], tools: ['global', 'torch', 'terminal'],
git: ['global', 'torch', 'git', 'terminal'] git: ['global', 'torch', 'git', 'terminal'],
agents: ['global', 'torch', 'terminal']
} }
let currentPage: PageName | null = null let currentPage: PageName | null = null

View File

@@ -41,7 +41,8 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
database: 'Database - Explorador de base de datos', database: 'Database - Explorador de base de datos',
source: 'Source - Navegador de codigo fuente', source: 'Source - Navegador de codigo fuente',
terminal: 'Terminal - Consola de comandos', terminal: 'Terminal - Consola de comandos',
tools: 'Tools - Gestion de herramientas MCP' tools: 'Tools - Gestion de herramientas MCP',
agents: 'Agents - Editor de configuracion de agentes Claude Code'
} }
const pageName = route.name as string || 'unknown' const pageName = route.name as string || 'unknown'
@@ -62,7 +63,7 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
properties: { properties: {
page: { page: {
type: 'string', type: 'string',
enum: ['home', 'canvas', 'components', 'themes', 'database', 'source', 'terminal', 'tools'], enum: ['home', 'canvas', 'components', 'themes', 'database', 'source', 'terminal', 'tools', 'agents'],
description: 'Pagina a la que navegar' description: 'Pagina a la que navegar'
} }
}, },
@@ -81,7 +82,8 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
database: '/database', database: '/database',
source: '/source', source: '/source',
terminal: '/terminal', terminal: '/terminal',
tools: '/tools' tools: '/tools',
agents: '/agents'
} }
const path = routes[args.page] const path = routes[args.page]

View File

@@ -0,0 +1,204 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other'
interface AgentFile {
name: string
path: string
type: 'json' | 'markdown' | 'text' | 'jsonl'
category: FileCategory
size: number
}
interface Agent {
id: string
name: string
directory: string
files: AgentFile[]
}
interface OpenFile {
path: string
name: string
type: string
content: string
originalContent: string
agentId: string
category: FileCategory
}
export interface CategoryInfo {
key: FileCategory
label: string
icon: string // SVG path
color: string
files: AgentFile[]
}
const CATEGORY_META: Record<FileCategory, { label: string; icon: string; color: string; order: number }> = {
config: { label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0-6 0', color: '#6366f1', order: 0 },
instructions: { label: 'Instructions', icon: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20|M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z', color: '#3b82f6', order: 1 },
plugins: { label: 'Plugins', icon: 'M12 2L2 7l10 5 10-5-10-5z|M2 17l10 5 10-5|M2 12l10 5 10-5', color: '#8b5cf6', order: 2 },
history: { label: 'History', icon: 'M12 8v4l3 3|M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z', color: '#f59e0b', order: 3 },
debug: { label: 'Debug logs', icon: 'M12 12m-1 0a1 1 0 1 0 2 0 1 1 0 0 0-2 0|M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z', color: '#ef4444', order: 4 },
cache: { label: 'Cache', icon: 'M22 12H2|M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z', color: '#06b6d4', order: 5 },
sessions: { label: 'Session data', icon: 'M4 17l6-6 4 4 6-6|M4 17h16', color: '#10b981', order: 6 },
backups: { label: 'Backups', icon: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8|M21 3v5h-5', color: '#9ca3af', order: 7 },
other: { label: 'Other files', icon: 'M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z|M14 2v6h6', color: '#78716c', order: 8 }
}
export { CATEGORY_META }
export type { FileCategory, AgentFile, Agent }
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export { formatSize }
export const useAgentsStore = defineStore('agents', () => {
const agents = ref<Agent[]>([])
const selectedAgentId = ref<string | null>(null)
const openFile = ref<OpenFile | null>(null)
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const collapsedCategories = ref<Set<string>>(new Set())
const isDirty = computed(() => {
if (!openFile.value) return false
return openFile.value.content !== openFile.value.originalContent
})
const selectedAgent = computed(() => {
if (!selectedAgentId.value) return null
return agents.value.find(a => a.id === selectedAgentId.value) || null
})
const groupedFiles = computed((): CategoryInfo[] => {
const agent = selectedAgent.value
if (!agent) return []
const groups = new Map<FileCategory, AgentFile[]>()
for (const file of agent.files) {
const cat = file.category
if (!groups.has(cat)) groups.set(cat, [])
groups.get(cat)!.push(file)
}
return Array.from(groups.entries())
.map(([key, files]) => ({
key,
label: CATEGORY_META[key].label,
icon: CATEGORY_META[key].icon,
color: CATEGORY_META[key].color,
files
}))
.sort((a, b) => CATEGORY_META[a.key].order - CATEGORY_META[b.key].order)
})
async function fetchAgents() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/agents')
if (!res.ok) throw new Error('Failed to fetch agents')
agents.value = await res.json()
if (agents.value.length && !selectedAgentId.value) {
selectedAgentId.value = agents.value[0].id
}
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function selectAgent(id: string) {
selectedAgentId.value = id
openFile.value = null
}
async function loadFile(agentId: string, file: AgentFile) {
error.value = null
try {
const res = await fetch(`/api/agents/file?path=${encodeURIComponent(file.path)}`)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to load file')
}
const data = await res.json()
openFile.value = {
path: file.path,
name: file.name,
type: file.type,
content: data.content,
originalContent: data.content,
agentId,
category: file.category
}
} catch (e: any) {
error.value = e.message
}
}
async function saveFile() {
if (!openFile.value || !isDirty.value) return
saving.value = true
error.value = null
try {
const res = await fetch('/api/agents/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: openFile.value.path,
content: openFile.value.content
})
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Failed to save file')
}
openFile.value.originalContent = openFile.value.content
} catch (e: any) {
error.value = e.message
} finally {
saving.value = false
}
}
function revertFile() {
if (!openFile.value) return
openFile.value.content = openFile.value.originalContent
}
function toggleCategory(key: string) {
if (collapsedCategories.value.has(key)) {
collapsedCategories.value.delete(key)
} else {
collapsedCategories.value.add(key)
}
}
return {
agents,
selectedAgentId,
selectedAgent,
openFile,
loading,
saving,
error,
collapsedCategories,
isDirty,
groupedFiles,
fetchAgents,
selectAgent,
loadFile,
saveFile,
revertFile,
toggleCategory
}
})

239
server/routes/agents.ts Normal file
View File

@@ -0,0 +1,239 @@
import { jsonResponse, errorResponse } from '../utils/cors'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { join, resolve, basename } from 'path'
const PROJECT_ROOT = resolve(import.meta.dir, '../..')
// Allowed config file patterns (for path validation on read/write)
const ALLOWED_PATTERNS = [
/^\.claude\/.*$/,
/^\.claude-[^/]+\/.*$/,
/^CLAUDE\.md$/,
/^\.mcp\.json$/
]
// Skip .git internals (hundreds of binary/pack files, useless for debugging)
const SKIP_DIRS = ['.git']
// Sensitive files blocked from read/write via API
const BLOCKED_FILES = ['.credentials.json']
type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other'
interface AgentFile {
name: string
path: string
type: 'json' | 'markdown' | 'text' | 'jsonl'
category: FileCategory
size: number
}
interface UiConfig {
label: string
shortLabel: string
color: string
gradient: string
terminalBg: string
terminalBorder: string
enabled: boolean
}
interface Agent {
id: string
name: string
directory: string
files: AgentFile[]
uiConfig: UiConfig | null
}
function getFileType(filename: string): AgentFile['type'] {
if (filename.endsWith('.jsonl')) return 'jsonl'
if (filename.endsWith('.json')) return 'json'
if (filename.endsWith('.md')) return 'markdown'
return 'text'
}
function categorizeFile(relPath: string, filename: string): FileCategory {
// Backups first (most specific)
if (filename.includes('.backup')) return 'backups'
// By directory
if (relPath.includes('/plugins/')) return 'plugins'
if (relPath.includes('/debug/')) return 'debug'
if (relPath.includes('/cache/')) return 'cache'
if (relPath.includes('/session-env/') || relPath.includes('/shell-snapshots/') || relPath.includes('/todos/') || relPath.includes('/projects/')) return 'sessions'
// By filename
if (filename === 'history.jsonl') return 'history'
if (filename.endsWith('.md')) return 'instructions'
if (filename === 'settings.json' || filename === '.claude.json' || filename === '.mcp.json') return 'config'
if (filename.endsWith('.json')) return 'config'
return 'other'
}
function isBlocked(filename: string): boolean {
return BLOCKED_FILES.includes(filename)
}
function isAllowedPath(relativePath: string): boolean {
const normalized = relativePath.replace(/\\/g, '/')
return ALLOWED_PATTERNS.some(p => p.test(normalized))
}
function scanDirectory(dir: string, relBase: string): AgentFile[] {
const files: AgentFile[] = []
if (!existsSync(dir)) return files
try {
const entries = readdirSync(dir)
for (const entry of entries) {
const fullPath = join(dir, entry)
const relPath = `${relBase}/${entry}`
let stat
try { stat = statSync(fullPath) } catch { continue }
if (isBlocked(entry)) continue
if (stat.isFile()) {
files.push({
name: entry,
path: relPath,
type: getFileType(entry),
category: categorizeFile(relPath, entry),
size: stat.size
})
} else if (stat.isDirectory()) {
if (SKIP_DIRS.includes(entry)) continue
files.push(...scanDirectory(fullPath, relPath))
}
}
} catch { /* permission errors */ }
return files
}
function readUiConfig(agentDir: string): UiConfig | null {
const uiPath = join(agentDir, 'ui.json')
if (!existsSync(uiPath)) return null
try {
const raw = readFileSync(uiPath, 'utf-8')
const data = JSON.parse(raw)
return {
label: data.label || '',
shortLabel: data.shortLabel || '',
color: data.color || '#6366f1',
gradient: data.gradient || '',
terminalBg: data.terminalBg || '#0f0a1a',
terminalBorder: data.terminalBorder || '#6366f1',
enabled: data.enabled !== false
}
} catch {
return null
}
}
function discoverAgents(): Agent[] {
const agents: Agent[] = []
// Main agent (.claude/ + root files)
const claudeDir = join(PROJECT_ROOT, '.claude')
if (existsSync(claudeDir)) {
const mainAgent: Agent = {
id: 'main',
name: 'Claude Code (main)',
directory: '.claude',
files: scanDirectory(claudeDir, '.claude'),
uiConfig: readUiConfig(claudeDir)
}
// Root CLAUDE.md
const claudeMd = join(PROJECT_ROOT, 'CLAUDE.md')
if (existsSync(claudeMd)) {
const stat = statSync(claudeMd)
mainAgent.files.unshift({
name: 'CLAUDE.md', path: 'CLAUDE.md', type: 'markdown',
category: 'instructions', size: stat.size
})
}
// Root .mcp.json
const mcpJson = join(PROJECT_ROOT, '.mcp.json')
if (existsSync(mcpJson)) {
const stat = statSync(mcpJson)
mainAgent.files.push({
name: '.mcp.json', path: '.mcp.json', type: 'json',
category: 'config', size: stat.size
})
}
agents.push(mainAgent)
}
// Other agents (.claude-*/)
try {
const rootEntries = readdirSync(PROJECT_ROOT)
for (const entry of rootEntries) {
if (!entry.startsWith('.claude-')) continue
const fullPath = join(PROJECT_ROOT, entry)
try { if (!statSync(fullPath).isDirectory()) continue } catch { continue }
const agentId = entry.replace('.claude-', '')
agents.push({
id: agentId,
name: agentId.charAt(0).toUpperCase() + agentId.slice(1),
directory: entry,
files: scanDirectory(fullPath, entry),
uiConfig: readUiConfig(fullPath)
})
}
} catch { /* ignore */ }
return agents
}
export async function handleAgents(req: Request): Promise<Response | null> {
if (req.method !== 'GET') return null
return jsonResponse(discoverAgents())
}
export async function handleAgentsFile(req: Request, url: URL): Promise<Response | null> {
const filePath = url.searchParams.get('path')
if (!filePath) return errorResponse('Missing "path" query parameter')
const normalized = filePath.replace(/\\/g, '/')
if (!isAllowedPath(normalized)) return errorResponse('Access denied: path not allowed', 403)
if (isBlocked(basename(normalized))) return errorResponse('Access denied: sensitive file', 403)
const absolutePath = resolve(PROJECT_ROOT, normalized)
if (!absolutePath.startsWith(PROJECT_ROOT)) return errorResponse('Access denied: path traversal', 403)
if (req.method === 'GET') {
if (!existsSync(absolutePath)) return errorResponse('File not found', 404)
try {
const content = await Bun.file(absolutePath).text()
return jsonResponse({ path: normalized, content })
} catch (e: any) {
return errorResponse(`Failed to read file: ${e.message}`, 500)
}
}
if (req.method === 'POST') {
try {
const body = await req.json()
if (typeof body.content !== 'string') return errorResponse('Missing "content" field')
if (normalized.endsWith('.json')) {
try { JSON.parse(body.content) } catch { return errorResponse('Invalid JSON content') }
}
await Bun.write(absolutePath, body.content)
return jsonResponse({ success: true, path: normalized })
} catch (e: any) {
return errorResponse(`Failed to write file: ${e.message}`, 500)
}
}
return null
}

View File

@@ -12,6 +12,7 @@ import { handleRecordingsRoutes } from './recordings'
import { handleClaudeStatus } from './claude-status' import { handleClaudeStatus } from './claude-status'
import { handleSnapshots, handleSnapshotById } from './snapshots' import { handleSnapshots, handleSnapshotById } from './snapshots'
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git' import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
import { handleAgents, handleAgentsFile } from './agents'
export async function handleRequest(req: Request): Promise<Response> { export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
@@ -247,5 +248,15 @@ export async function handleRequest(req: Request): Promise<Response> {
return handleGitFile(url) return handleGitFile(url)
} }
// Agents
if (path === '/api/agents' && req.method === 'GET') {
return handleAgents(req)
}
if (path === '/api/agents/file') {
const res = await handleAgentsFile(req, url)
if (res) return res
}
return notFoundResponse() return notFoundResponse()
} }