Skip to content

Commit d6dc245

Browse files
DavidsonGomesclaude
andcommitted
release: v0.18.8 — multi-terminal tabs + recent agents + systemd fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 901ffc4 commit d6dc245

File tree

7 files changed

+344
-19
lines changed

7 files changed

+344
-19
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.18.8] - 2026-04-13
9+
10+
### Added
11+
12+
- **Multi-terminal tabs per agent** — each agent page now supports multiple terminal sessions with a tab bar. Create new terminals with the `+` button, switch between them, and close sessions individually. Backend adds `GET /api/sessions/by-agent/:name` and `POST /api/sessions/create` endpoints
13+
- **Recent Agents section** — the Agents page shows the last 6 visited agents at the top for quick access, with avatar, name, command, and running indicator. Tracked via localStorage
14+
15+
### Fixed
16+
17+
- **systemd KillMode=none** — nohup background processes (Flask, terminal-server) were being killed when the oneshot ExecStart script finished. `KillMode=none` prevents systemd from sending SIGTERM to child processes
18+
- **install-service.sh regenerates start-services.sh** — the copied script had hardcoded `/root/` paths from the original installation, causing `Permission denied` errors when running as the `evonexus` user
19+
820
## [0.18.7] - 2026-04-12
921

1022
### Added

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.18.7",
3+
"version": "0.18.8",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/frontend/src/components/AgentTerminal.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import '@xterm/xterm/css/xterm.css'
66

77
interface AgentTerminalProps {
88
agent: string
9+
sessionId?: string
910
workingDir?: string
1011
accentColor?: string
1112
}
@@ -24,7 +25,7 @@ const CC_WEB_WS = isLocal
2425

2526
type Status = 'connecting' | 'ready' | 'starting' | 'running' | 'error' | 'exited'
2627

27-
export default function AgentTerminal({ agent, workingDir, accentColor = '#00FFA7' }: AgentTerminalProps) {
28+
export default function AgentTerminal({ agent, sessionId: externalSessionId, workingDir, accentColor = '#00FFA7' }: AgentTerminalProps) {
2829
const containerRef = useRef<HTMLDivElement>(null)
2930
const termRef = useRef<Terminal | null>(null)
3031
const fitRef = useRef<FitAddon | null>(null)
@@ -140,19 +141,30 @@ export default function AgentTerminal({ agent, workingDir, accentColor = '#00FFA
140141
setErrorMsg(null)
141142
term!.clear()
142143

143-
// 1) Find-or-create session for this agent
144+
// 1) Use provided sessionId or find-or-create for this agent
144145
let sessionId: string
145146
let alreadyActive = false
146147
try {
147-
const res = await fetch(`${CC_WEB_HTTP}/api/sessions/for-agent`, {
148-
method: 'POST',
149-
headers: { 'Content-Type': 'application/json' },
150-
body: JSON.stringify({ agentName: agent, workingDir }),
151-
})
152-
if (!res.ok) throw new Error(`HTTP ${res.status}`)
153-
const data = await res.json()
154-
sessionId = data.sessionId
155-
alreadyActive = !!data.session?.active
148+
if (externalSessionId) {
149+
// Use the specific session provided by the parent (multi-tab mode)
150+
sessionId = externalSessionId
151+
const infoRes = await fetch(`${CC_WEB_HTTP}/api/sessions/${externalSessionId}`)
152+
if (infoRes.ok) {
153+
const info = await infoRes.json()
154+
alreadyActive = !!info.active
155+
}
156+
} else {
157+
// Default: find-or-create session for this agent
158+
const res = await fetch(`${CC_WEB_HTTP}/api/sessions/for-agent`, {
159+
method: 'POST',
160+
headers: { 'Content-Type': 'application/json' },
161+
body: JSON.stringify({ agentName: agent, workingDir }),
162+
})
163+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
164+
const data = await res.json()
165+
sessionId = data.sessionId
166+
alreadyActive = !!data.session?.active
167+
}
156168
} catch (e: any) {
157169
if (cancelled) return
158170
setStatus('error')
@@ -277,7 +289,7 @@ export default function AgentTerminal({ agent, workingDir, accentColor = '#00FFA
277289
wsRef.current = null
278290
}
279291
}
280-
}, [agent, workingDir])
292+
}, [agent, externalSessionId, workingDir])
281293

282294
const statusDotColor =
283295
status === 'running'

dashboard/frontend/src/pages/AgentDetail.tsx

Lines changed: 167 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { useEffect, useState } from 'react'
1+
import { useEffect, useState, useCallback } from 'react'
22
import { useParams, Link } from 'react-router-dom'
3-
import { ArrowLeft, ChevronDown, ChevronRight, PanelLeft, X, Lock } from 'lucide-react'
3+
import { ArrowLeft, ChevronDown, ChevronRight, PanelLeft, X, Lock, Plus, Terminal as TerminalIcon } from 'lucide-react'
44
import { api } from '../lib/api'
55
import Markdown from '../components/Markdown'
66
import AgentTerminal from '../components/AgentTerminal'
77
import { getAgentMeta } from '../lib/agent-meta'
8+
import { trackAgentVisit } from './Agents'
89
import { AgentAvatar } from '../components/AgentAvatar'
910
import { useAuth } from '../context/AuthContext'
1011

@@ -16,6 +17,18 @@ interface MemoryFile {
1617

1718
type Tab = 'profile' | 'memory'
1819

20+
// Terminal-server URL (same logic as AgentTerminal)
21+
const isLocal = import.meta.env.DEV || /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname)
22+
const TS_HTTP = isLocal
23+
? `http://${window.location.hostname}:32352`
24+
: `${window.location.origin}/terminal`
25+
26+
interface TerminalTab {
27+
id: string // sessionId
28+
name: string // display name
29+
active: boolean // is claude running
30+
}
31+
1932
function formatSize(bytes: number): string {
2033
if (bytes < 1024) return `${bytes}b`
2134
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}kb`
@@ -40,6 +53,93 @@ export default function AgentDetail() {
4053
const [tab, setTab] = useState<Tab>('profile')
4154
const [railOpen, setRailOpen] = useState(false) // mobile drawer
4255

56+
// Multi-terminal tabs
57+
const [termTabs, setTermTabs] = useState<TerminalTab[]>([])
58+
const [activeTermTab, setActiveTermTab] = useState<string | null>(null)
59+
const [, setTermTabsLoading] = useState(true)
60+
61+
// Track agent visit for "Recent" section
62+
useEffect(() => {
63+
if (name) trackAgentVisit(name)
64+
}, [name])
65+
66+
// Load existing terminal sessions for this agent
67+
useEffect(() => {
68+
if (!name) return
69+
setTermTabsLoading(true)
70+
fetch(`${TS_HTTP}/api/sessions/by-agent/${name}`)
71+
.then(r => r.ok ? r.json() : { sessions: [] })
72+
.then(data => {
73+
const sessions: TerminalTab[] = (data.sessions || []).map((s: any) => ({
74+
id: s.id,
75+
name: s.name || name,
76+
active: s.active,
77+
}))
78+
if (sessions.length === 0) {
79+
// No existing sessions — will use default find-or-create (no tab needed yet)
80+
setTermTabs([])
81+
setActiveTermTab(null)
82+
} else {
83+
setTermTabs(sessions)
84+
setActiveTermTab(sessions[0].id)
85+
}
86+
})
87+
.catch(() => {
88+
setTermTabs([])
89+
setActiveTermTab(null)
90+
})
91+
.finally(() => setTermTabsLoading(false))
92+
}, [name])
93+
94+
const createNewTerminal = useCallback(async () => {
95+
if (!name) return
96+
try {
97+
const res = await fetch(`${TS_HTTP}/api/sessions/create`, {
98+
method: 'POST',
99+
headers: { 'Content-Type': 'application/json' },
100+
body: JSON.stringify({ agentName: name }),
101+
})
102+
if (!res.ok) return
103+
const data = await res.json()
104+
const newTab: TerminalTab = {
105+
id: data.sessionId,
106+
name: data.session?.name || `${name} #${termTabs.length + 1}`,
107+
active: false,
108+
}
109+
// If this is the first extra tab, we also need to load the existing default session
110+
if (termTabs.length === 0 && activeTermTab === null) {
111+
// Fetch existing sessions first to get the default one
112+
const existing = await fetch(`${TS_HTTP}/api/sessions/by-agent/${name}`)
113+
if (existing.ok) {
114+
const existingData = await existing.json()
115+
const allSessions: TerminalTab[] = (existingData.sessions || [])
116+
.filter((s: any) => s.id !== data.sessionId)
117+
.map((s: any) => ({ id: s.id, name: s.name || name, active: s.active }))
118+
setTermTabs([...allSessions, newTab])
119+
} else {
120+
setTermTabs([newTab])
121+
}
122+
} else {
123+
setTermTabs(prev => [...prev, newTab])
124+
}
125+
setActiveTermTab(data.sessionId)
126+
} catch {}
127+
}, [name, termTabs, activeTermTab])
128+
129+
const closeTerminalTab = useCallback(async (sessionId: string) => {
130+
// Stop and delete session
131+
try {
132+
await fetch(`${TS_HTTP}/api/sessions/${sessionId}`, { method: 'DELETE' })
133+
} catch {}
134+
setTermTabs(prev => {
135+
const next = prev.filter(t => t.id !== sessionId)
136+
if (activeTermTab === sessionId) {
137+
setActiveTermTab(next.length > 0 ? next[0].id : null)
138+
}
139+
return next
140+
})
141+
}, [activeTermTab])
142+
43143
useEffect(() => {
44144
if (!name) return
45145
setLoading(true)
@@ -226,7 +326,7 @@ export default function AgentDetail() {
226326
)}
227327

228328
{/* Terminal stage */}
229-
<section className="flex-1 min-w-0 relative bg-[#0C111D] overflow-hidden">
329+
<section className="flex-1 min-w-0 relative bg-[#0C111D] overflow-hidden flex flex-col">
230330
{/* Ambient glow */}
231331
<div
232332
className="pointer-events-none absolute top-0 right-0 h-[400px] w-[400px] blur-3xl"
@@ -235,8 +335,70 @@ export default function AgentDetail() {
235335
opacity: 0.06,
236336
}}
237337
/>
238-
<div className="relative z-10 h-full">
239-
<AgentTerminal agent={name} accentColor={agentColor} />
338+
339+
{/* Terminal tabs bar — only show when there are multiple tabs */}
340+
{termTabs.length > 1 && (
341+
<div className="relative z-10 flex items-center flex-shrink-0 h-9 border-b border-[#21262d] bg-[#0d1117] overflow-x-auto">
342+
{termTabs.map((tt) => (
343+
<div
344+
key={tt.id}
345+
className={`group flex items-center gap-2 px-3 h-full text-[11px] cursor-pointer border-r border-[#21262d] transition-colors ${
346+
activeTermTab === tt.id
347+
? 'bg-[#0C111D] text-[#e6edf3]'
348+
: 'text-[#8b949e] hover:text-[#e6edf3] hover:bg-[#161b22]'
349+
}`}
350+
onClick={() => setActiveTermTab(tt.id)}
351+
>
352+
<TerminalIcon size={11} style={{ color: activeTermTab === tt.id ? agentColor : undefined }} />
353+
<span className="truncate max-w-[120px]">{tt.name}</span>
354+
{tt.active && (
355+
<span
356+
className="inline-block h-1.5 w-1.5 rounded-full flex-shrink-0"
357+
style={{ backgroundColor: agentColor, boxShadow: `0 0 4px ${agentColor}88` }}
358+
/>
359+
)}
360+
{termTabs.length > 1 && (
361+
<button
362+
onClick={(e) => { e.stopPropagation(); closeTerminalTab(tt.id) }}
363+
className="opacity-0 group-hover:opacity-100 text-[#667085] hover:text-[#ef4444] transition-opacity"
364+
>
365+
<X size={11} />
366+
</button>
367+
)}
368+
</div>
369+
))}
370+
{/* New tab button */}
371+
<button
372+
onClick={createNewTerminal}
373+
className="flex items-center justify-center h-full px-2.5 text-[#667085] hover:text-[#e6edf3] hover:bg-[#161b22] transition-colors"
374+
title="New terminal"
375+
>
376+
<Plus size={13} />
377+
</button>
378+
</div>
379+
)}
380+
381+
{/* Single "+" button when only one tab (or none) — show as floating button */}
382+
{termTabs.length <= 1 && (
383+
<button
384+
onClick={createNewTerminal}
385+
className="absolute top-1.5 right-3 z-20 flex items-center gap-1 px-2 py-1 rounded text-[10px] text-[#667085] hover:text-[#e6edf3] bg-[#0d1117]/80 hover:bg-[#161b22] border border-[#21262d] transition-colors"
386+
title="New terminal"
387+
>
388+
<Plus size={11} />
389+
<span className="hidden sm:inline">New</span>
390+
</button>
391+
)}
392+
393+
{/* Terminal content */}
394+
<div className="relative z-10 flex-1 min-h-0">
395+
{termTabs.length === 0 || activeTermTab === null ? (
396+
// Default mode — single terminal, no explicit sessionId
397+
<AgentTerminal key={`default-${name}`} agent={name} accentColor={agentColor} />
398+
) : (
399+
// Multi-tab mode — render the active session
400+
<AgentTerminal key={activeTermTab} agent={name} sessionId={activeTermTab} accentColor={agentColor} />
401+
)}
240402
</div>
241403
</section>
242404
</div>

dashboard/frontend/src/pages/Agents.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,12 +610,37 @@ function SubSectionHeader({ label, count }: { label: string; count: number }) {
610610
)
611611
}
612612

613+
// Recent agents — persisted in localStorage
614+
const RECENT_KEY = 'evo:recent-agents'
615+
const MAX_RECENT = 6
616+
617+
function getRecentAgents(): string[] {
618+
try {
619+
const raw = localStorage.getItem(RECENT_KEY)
620+
if (!raw) return []
621+
const data = JSON.parse(raw) as { name: string; ts: number }[]
622+
return data.sort((a, b) => b.ts - a.ts).map(d => d.name).slice(0, MAX_RECENT)
623+
} catch { return [] }
624+
}
625+
626+
export function trackAgentVisit(agentName: string) {
627+
try {
628+
const raw = localStorage.getItem(RECENT_KEY)
629+
let data: { name: string; ts: number }[] = raw ? JSON.parse(raw) : []
630+
data = data.filter(d => d.name !== agentName)
631+
data.unshift({ name: agentName, ts: Date.now() })
632+
data = data.slice(0, MAX_RECENT)
633+
localStorage.setItem(RECENT_KEY, JSON.stringify(data))
634+
} catch {}
635+
}
636+
613637
export default function Agents() {
614638
const [agents, setAgents] = useState<Agent[]>([])
615639
const [loading, setLoading] = useState(true)
616640
const [runningAgents, setRunningAgents] = useState<string[]>([])
617641
const [filter, setFilter] = useState<FilterValue>('all')
618642
const [query, setQuery] = useState('')
643+
const [recentNames] = useState(getRecentAgents)
619644

620645
useEffect(() => {
621646
api.get('/agents')
@@ -750,6 +775,49 @@ export default function Agents() {
750775
)}
751776
</div>
752777

778+
{/* Recent agents */}
779+
{!loading && recentNames.length > 0 && filter === 'all' && !query && (
780+
<div className="mb-6">
781+
<div className="flex items-center gap-2 mb-3">
782+
<History size={13} className="text-[#667085]" />
783+
<h2 className="text-[11px] font-medium uppercase tracking-wider text-[#667085]">Recent</h2>
784+
</div>
785+
<div className="flex gap-2 overflow-x-auto pb-1">
786+
{recentNames
787+
.filter(n => agents.some(a => a.name === n))
788+
.map(name => {
789+
const meta = AGENT_META[name] || DEFAULT_META
790+
const running = isRunning(name)
791+
return (
792+
<Link
793+
key={name}
794+
to={`/agents/${name}`}
795+
className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-[#21262d] bg-[#161b22] hover:border-[#30363d] hover:bg-[#1c2333] transition-all flex-shrink-0 group"
796+
>
797+
<div className="relative flex-shrink-0">
798+
<AgentAvatar name={name} size={28} />
799+
{running && (
800+
<span
801+
className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-[#161b22]"
802+
style={{ backgroundColor: '#22C55E', boxShadow: '0 0 6px rgba(34,197,94,0.5)' }}
803+
/>
804+
)}
805+
</div>
806+
<div className="flex flex-col min-w-0">
807+
<span className="text-[12px] font-medium text-[#e6edf3] group-hover:text-white truncate">
808+
{name.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' ')}
809+
</span>
810+
<span className="text-[10px] font-mono" style={{ color: meta.color }}>
811+
{meta.command}
812+
</span>
813+
</div>
814+
</Link>
815+
)
816+
})}
817+
</div>
818+
</div>
819+
)}
820+
753821
{/* Filter + Search bar */}
754822
{!loading && agents.length > 0 && (
755823
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">

0 commit comments

Comments
 (0)