From 22f8c710dd997aa950977b25be1f8fd5914045aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 04:09:03 +0000 Subject: [PATCH 1/7] Run dashboard Terminal commands through PowerShell on Windows subprocess.run(..., shell=True) uses cmd.exe on Windows by default, which doesn't understand PowerShell syntax like $env:VAR or $env:USERPROFILE - commands using it failed with 'cannot find the file specified' since cmd took it as a literal filename. Invoke powershell.exe explicitly on Windows instead; POSIX behavior is unchanged. --- server.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/server.py b/server.py index 41bfe56..ee525e5 100644 --- a/server.py +++ b/server.py @@ -692,10 +692,16 @@ def run_terminal_command(req: TerminalRunRequest): return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False} try: - r = subprocess.run( - command, shell=True, cwd=_terminal_cwd, - capture_output=True, text=True, timeout=60, - ) + if os.name == "nt": + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + cwd=_terminal_cwd, capture_output=True, text=True, timeout=60, + ) + else: + r = subprocess.run( + command, shell=True, cwd=_terminal_cwd, + capture_output=True, text=True, timeout=60, + ) append_audit({"action": "terminal_command", "command": command[:200], "cwd": _terminal_cwd}) return {"cwd": _terminal_cwd, "stdout": r.stdout, "stderr": r.stderr, "returncode": r.returncode, "timed_out": False} except subprocess.TimeoutExpired: From d7a1cbd292605bbe2b9cabfbe313daa77181526a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 05:12:54 +0000 Subject: [PATCH 2/7] Replace command-runner Terminal with a real interactive PTY The previous Terminal ran one command at a time via subprocess.run and returned its output - it couldn't run interactive programs (colors, live input, TUIs like gemini's chat/auth flow), which is what a terminal actually needs to do. Backend: new /ws/terminal WebSocket endpoint spawns a real shell attached to a pseudo-terminal (stdlib pty on POSIX, pywinpty/ConPTY on Windows) and streams raw I/O bidirectionally, with resize support. Replaces the old POST /api/terminal/run and GET /api/terminal/session endpoints entirely. Frontend: terminal.js now loads xterm.js + the fit addon from CDN and renders a real terminal emulator wired to the WebSocket, instead of a scrollback div with a single input line. Verified on this Linux sandbox via raw WebSocket tests: shell spawns correctly, commands execute and echo real output, resize propagates to the PTY (confirmed via ), and closing the connection cleanly kills the shell process with no orphans (interactive bash ignores SIGTERM by default, so cleanup uses SIGKILL). Could not visually verify the xterm.js browser rendering in this sandbox since its egress policy blocks the CDN (cdn.jsdelivr.net) outright - same CDN this app already uses for chart.js, so expected to work on a normal machine; please confirm on Windows. --- dashboard/api.js | 3 - dashboard/pages/terminal.js | 198 +++++++++++++++++++----------------- dashboard/styles.css | 25 +---- requirements.txt | 1 + server.py | 146 +++++++++++++++++++------- 5 files changed, 216 insertions(+), 157 deletions(-) diff --git a/dashboard/api.js b/dashboard/api.js index b2bf437..8f36079 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -52,9 +52,6 @@ const api = { discoverStandards: () => api.post('/api/standards/discover'), chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller), getChatHistory: () => api.get('/api/chat/history'), - // Terminal - getTerminalSession: () => api.get('/api/terminal/session'), - runTerminalCommand: (command) => api.post('/api/terminal/run', { command }), // Kanban getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'), getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`), diff --git a/dashboard/pages/terminal.js b/dashboard/pages/terminal.js index c05afc1..25548a1 100644 --- a/dashboard/pages/terminal.js +++ b/dashboard/pages/terminal.js @@ -1,114 +1,120 @@ +let _termInstance = null; +let _termSocket = null; + +function loadXterm() { + if (window.Terminal && window.FitAddon) return Promise.resolve(); + return new Promise((resolve, reject) => { + if (!document.querySelector('link[data-xterm-css]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css'; + link.setAttribute('data-xterm-css', '1'); + document.head.appendChild(link); + } + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js'; + script.onload = () => { + const fitScript = document.createElement('script'); + fitScript.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js'; + fitScript.onload = () => resolve(); + fitScript.onerror = () => reject(new Error('Failed to load xterm-addon-fit')); + document.body.appendChild(fitScript); + }; + script.onerror = () => reject(new Error('Failed to load xterm.js')); + document.body.appendChild(script); + }); +} + +function setTerminalStatus(kind, text) { + const el = document.getElementById('terminalStatus'); + if (!el) return; + el.textContent = text; + el.className = `badge badge-${kind === 'online' ? 'success' : kind === 'offline' ? 'danger' : 'warning'}`; +} + +function closeTerminalSession() { + if (window._terminalResizeHandler) { + window.removeEventListener('resize', window._terminalResizeHandler); + window._terminalResizeHandler = null; + } + if (_termSocket) { + try { _termSocket.close(); } catch {} + _termSocket = null; + } + if (_termInstance) { + try { _termInstance.dispose(); } catch {} + _termInstance = null; + } +} + async function renderTerminal() { const content = document.getElementById('pageContent'); content.innerHTML = ` -
-
-
- $ - -
+
+
`; - window._terminalHistory = window._terminalHistory || []; - window._terminalHistoryIndex = window._terminalHistory.length; - window._terminalBusy = false; + closeTerminalSession(); try { - const session = await api.getTerminalSession(); - updateTerminalPrompt(session.cwd); - } catch { - appendTerminalLine('Could not reach the terminal backend.', 'stderr'); - } - - document.getElementById('terminalInput').focus(); -} - -function focusTerminalInput() { - const input = document.getElementById('terminalInput'); - if (input) input.focus(); -} - -function updateTerminalPrompt(cwd) { - const prompt = document.getElementById('terminalPrompt'); - if (prompt) prompt.textContent = `${cwd} $`; -} - -function appendTerminalLine(text, kind = '') { - const output = document.getElementById('terminalOutput'); - if (!output || !text) return; - const line = document.createElement('div'); - if (kind) line.className = `terminal-line-${kind}`; - line.textContent = text; - output.appendChild(line); - output.scrollTop = output.scrollHeight; -} - -function clearTerminal() { - const output = document.getElementById('terminalOutput'); - if (output) output.innerHTML = ''; -} - -async function handleTerminalKey(e) { - const input = e.target; - - if (e.key === 'ArrowUp') { - e.preventDefault(); - if (window._terminalHistoryIndex > 0) { - window._terminalHistoryIndex--; - input.value = window._terminalHistory[window._terminalHistoryIndex] || ''; - } - return; - } - if (e.key === 'ArrowDown') { - e.preventDefault(); - if (window._terminalHistoryIndex < window._terminalHistory.length - 1) { - window._terminalHistoryIndex++; - input.value = window._terminalHistory[window._terminalHistoryIndex] || ''; - } else { - window._terminalHistoryIndex = window._terminalHistory.length; - input.value = ''; - } - return; - } - if (e.key !== 'Enter' || window._terminalBusy) return; - - const command = input.value.trim(); - input.value = ''; - if (!command) return; - - window._terminalHistory.push(command); - window._terminalHistoryIndex = window._terminalHistory.length; - - appendTerminalLine(command, 'cmd'); - - if (command === 'clear' || command === 'cls') { - clearTerminal(); - return; - } - - window._terminalBusy = true; - input.disabled = true; - try { - const r = await api.runTerminalCommand(command); - if (r.stdout) appendTerminalLine(r.stdout.replace(/\n$/, '')); - if (r.stderr) appendTerminalLine(r.stderr.replace(/\n$/, ''), 'stderr'); - if (r.timed_out) appendTerminalLine('Command timed out after 60s.', 'info'); - updateTerminalPrompt(r.cwd); + await loadXterm(); } catch (err) { - appendTerminalLine(`Error: ${err.message}`, 'stderr'); - } finally { - window._terminalBusy = false; - input.disabled = false; - input.focus(); + document.getElementById('xtermContainer').innerHTML = + `
⚠
Failed to load terminal library
${escapeHtml(err.message || String(err))}
`; + return; } + + const term = new window.Terminal({ + cursorBlink: true, + fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace", + fontSize: 13, + theme: { background: '#0a0e14', foreground: '#c9d1d9' }, + }); + const fitAddon = new window.FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById('xtermContainer')); + fitAddon.fit(); + _termInstance = term; + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket(`${proto}//${window.location.host}/ws/terminal`); + _termSocket = socket; + + socket.onopen = () => { + setTerminalStatus('online', 'Connected'); + fitAddon.fit(); + socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); + term.focus(); + }; + socket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'output') term.write(msg.data); + } catch {} + }; + socket.onclose = () => setTerminalStatus('offline', 'Disconnected'); + socket.onerror = () => setTerminalStatus('offline', 'Connection error'); + + term.onData((data) => { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data })); + }); + + const resizeHandler = () => { + fitAddon.fit(); + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); + }; + window.addEventListener('resize', resizeHandler); + window._terminalResizeHandler = resizeHandler; + + window.addEventListener('hashchange', closeTerminalSession, { once: true }); } diff --git a/dashboard/styles.css b/dashboard/styles.css index 57921ed..79bdefd 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -824,34 +824,17 @@ pre { /* Terminal */ .terminal-panel { flex: 1; display: flex; flex-direction: column; - min-height: 0; + min-height: 0; height: calc(100vh - 180px); background: #0a0e14; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); - font-family: var(--font-mono); } -.terminal-output { - flex: 1; overflow-y: auto; padding: 16px; - font-size: 13px; line-height: 1.6; - color: #c9d1d9; white-space: pre-wrap; word-break: break-word; -} -.terminal-line-cmd { color: #7ee787; } -.terminal-line-cmd::before { content: '$ '; color: #58a6ff; } -.terminal-line-stderr { color: #ff7b72; } -.terminal-line-info { color: var(--text-muted); font-style: italic; } -.terminal-input-row { - display: flex; align-items: center; gap: 8px; - padding: 10px 16px; - background: #0d1117; - border-top: 1px solid var(--border); -} -.terminal-prompt { color: #58a6ff; font-size: 13px; white-space: nowrap; } -.terminal-input { - flex: 1; background: transparent; border: none; outline: none; - color: #c9d1d9; font-family: var(--font-mono); font-size: 13px; +.terminal-xterm-container { + flex: 1; min-height: 0; padding: 8px; } +.terminal-xterm-container .xterm { height: 100%; } /* ─── v0.2.0 UI Modernization ─── */ diff --git a/requirements.txt b/requirements.txt index 2d33445..a5e18cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]>=0.32.0 apscheduler>=3.10.0 pydantic>=2.0.0 python-multipart>=0.0.12 +pywinpty>=2.0.0; sys_platform == "win32" diff --git a/server.py b/server.py index ee525e5..f137ee7 100644 --- a/server.py +++ b/server.py @@ -4,10 +4,12 @@ Agentic OS β€” FastAPI Backend Multi-agent orchestration server for opencode, Hermes, Gemini CLI """ import argparse +import asyncio import json import os import re import shutil +import signal import subprocess import tarfile import threading @@ -17,7 +19,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles @@ -101,9 +103,6 @@ class ChatRequest(BaseModel): agent: str message: str -class TerminalRunRequest(BaseModel): - command: str - # ─── Helper Functions ───────────────────────────────────────────── def read_file(path: Path): @@ -667,45 +666,118 @@ def chat(req: ChatRequest): def get_chat_history(): return load_chat_history() -# ─── Routes: Terminal ───────────────────────────────────────────── +# ─── Routes: Terminal (real interactive PTY over WebSocket) ────── -_terminal_cwd = str(BASE_DIR) +class PtySession: + """Wraps a real pseudo-terminal shell process, POSIX (stdlib pty) or Windows (pywinpty).""" -@app.get("/api/terminal/session") -def get_terminal_session(): - return {"cwd": _terminal_cwd} + def __init__(self): + self.master_fd = None + self.pid = None + self.winpty_process = None -@app.post("/api/terminal/run") -def run_terminal_command(req: TerminalRunRequest): - global _terminal_cwd - command = req.command.strip() - if not command: - return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False} + def start(self, cwd: str): + if os.name == "nt": + import winpty + shell = shutil.which("powershell.exe") or "powershell.exe" + self.winpty_process = winpty.PtyProcess.spawn([shell, "-NoLogo"], cwd=cwd) + else: + import pty + pid, fd = pty.fork() + if pid == 0: + os.chdir(cwd) + shell = os.environ.get("SHELL", "/bin/bash") + os.execvp(shell, [shell]) + else: + self.master_fd = fd + self.pid = pid - if command == "cd" or command.startswith("cd "): - target = command[2:].strip() or str(Path.home()) - new_dir = (Path(_terminal_cwd) / target).resolve() if not Path(target).is_absolute() else Path(target).resolve() - if not new_dir.is_dir(): - return {"cwd": _terminal_cwd, "stdout": "", "stderr": f"cd: no such directory: {target}", "returncode": 1, "timed_out": False} - _terminal_cwd = str(new_dir) - append_audit({"action": "terminal_command", "command": "cd", "cwd": _terminal_cwd}) - return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False} + def read(self, size: int = 4096): + if self.winpty_process is not None: + return self.winpty_process.read(size) + return os.read(self.master_fd, size) + + def write(self, data: str): + if self.winpty_process is not None: + self.winpty_process.write(data) + else: + os.write(self.master_fd, data.encode()) + + def resize(self, cols: int, rows: int): + if self.winpty_process is not None: + self.winpty_process.setwinsize(rows, cols) + else: + import fcntl + import struct + import termios + fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + def close(self): + if self.winpty_process is not None: + try: + self.winpty_process.close(force=True) + except Exception: + pass + else: + try: + os.close(self.master_fd) + except OSError: + pass + if self.pid: + # Interactive shells commonly ignore SIGTERM; SIGKILL can't be blocked/ignored. + try: + os.kill(self.pid, signal.SIGKILL) + except OSError: + pass + +@app.websocket("/ws/terminal") +async def ws_terminal(websocket: WebSocket): + await websocket.accept() + session = PtySession() + try: + session.start(str(BASE_DIR)) + except Exception as e: + await websocket.send_json({"type": "output", "data": f"Failed to start terminal: {e}\r\n"}) + await websocket.close() + return + + append_audit({"action": "terminal_session_started"}) + loop = asyncio.get_event_loop() + stop = False + + def reader(): + while not stop: + try: + data = session.read(4096) + except (OSError, EOFError): + break + if not data: + break + text = data.decode(errors="replace") if isinstance(data, bytes) else data + fut = asyncio.run_coroutine_threadsafe(websocket.send_json({"type": "output", "data": text}), loop) + try: + fut.result(timeout=5) + except Exception: + break + asyncio.run_coroutine_threadsafe(websocket.close(), loop) + + threading.Thread(target=reader, daemon=True).start() try: - if os.name == "nt": - r = subprocess.run( - ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - cwd=_terminal_cwd, capture_output=True, text=True, timeout=60, - ) - else: - r = subprocess.run( - command, shell=True, cwd=_terminal_cwd, - capture_output=True, text=True, timeout=60, - ) - append_audit({"action": "terminal_command", "command": command[:200], "cwd": _terminal_cwd}) - return {"cwd": _terminal_cwd, "stdout": r.stdout, "stderr": r.stderr, "returncode": r.returncode, "timed_out": False} - except subprocess.TimeoutExpired: - return {"cwd": _terminal_cwd, "stdout": "", "stderr": "Command timed out after 60s.", "returncode": -1, "timed_out": True} + while True: + msg = await websocket.receive_json() + if msg.get("type") == "input": + session.write(msg.get("data", "")) + elif msg.get("type") == "resize": + session.resize(int(msg.get("cols", 80)), int(msg.get("rows", 24))) + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + stop = True + session.close() + append_audit({"action": "terminal_session_ended"}) # ═══════════════════════════════════════════════════════════════════ # v0.2.0 β€” New Feature Endpoints From 9e632ab1dc2618d9dd748e96b3db760b44046815 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:29:31 +0000 Subject: [PATCH 3/7] Remove demo/sample Kanban tasks These were placeholder seed data shipped with the repo (a demo 'Fix login bug' task and a 'Test kanban task' with a canned 'Waiting for API review' block reason) - not real tasks. Clearing them so the board starts empty. --- data/kanban/0f822987.json | 12 ------------ data/kanban/8893ad14.json | 15 --------------- 2 files changed, 27 deletions(-) delete mode 100644 data/kanban/0f822987.json delete mode 100644 data/kanban/8893ad14.json diff --git a/data/kanban/0f822987.json b/data/kanban/0f822987.json deleted file mode 100644 index 611d8f1..0000000 --- a/data/kanban/0f822987.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "0f822987", - "title": "Fix login bug", - "body": "The login page has a race condition", - "status": "todo", - "priority": "high", - "assignee": "opencode", - "comments": [], - "links": [], - "created": "2026-06-05T09:52:18.236452+00:00", - "updated": "2026-06-05T09:52:18.236473+00:00" -} \ No newline at end of file diff --git a/data/kanban/8893ad14.json b/data/kanban/8893ad14.json deleted file mode 100644 index 18adcf7..0000000 --- a/data/kanban/8893ad14.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "8893ad14", - "title": "Test kanban task", - "body": "", - "status": "blocked", - "priority": "medium", - "assignee": "", - "comments": [], - "links": [], - "created": "2026-06-05T09:50:30.293643+00:00", - "updated": "2026-06-05T09:52:18.274595+00:00", - "summary": "Test done", - "completed_at": "2026-06-05T09:50:46.473612+00:00", - "block_reason": "Waiting for API review" -} \ No newline at end of file From 2a5f0aee742611499adcb3a020ec7770d83e126f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:39:35 +0000 Subject: [PATCH 4/7] Add missing DELETE /api/kanban/tasks/{id} endpoint The Kanban detail modal's Delete button called api.deleteKanbanTask(), which didn't exist on the client, and there was no backend route for it either - clicking Delete just threw 'api.deleteKanbanTask is not a function'. Add both the client method and the backend endpoint. --- dashboard/api.js | 1 + server.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/dashboard/api.js b/dashboard/api.js index 8f36079..17a9704 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -55,6 +55,7 @@ const api = { // Kanban getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'), getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`), + deleteKanbanTask: (id) => api.del(`/api/kanban/tasks/${encodeURIComponent(id)}`), createKanbanTask: (data) => api.post('/api/kanban/tasks', data), updateKanbanTask: (id, data) => api.patch(`/api/kanban/tasks/${encodeURIComponent(id)}`, data), completeKanbanTask: (id, summary) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/complete`, { summary }), diff --git a/server.py b/server.py index f137ee7..1539908 100644 --- a/server.py +++ b/server.py @@ -947,6 +947,15 @@ def kanban_get_task(task_id: str): raise HTTPException(404, "Task not found") return json.loads(path.read_text()) +@app.delete("/api/kanban/tasks/{task_id}") +def kanban_delete_task(task_id: str): + path = kanban_task_path(task_id) + if not path.exists(): + raise HTTPException(404, "Task not found") + path.unlink() + append_audit({"action": "kanban_task_deleted", "task_id": task_id}) + return {"status": "deleted", "task_id": task_id} + @app.post("/api/kanban/tasks") def kanban_create_task(data: KanbanTaskCreate): try: From 085e500908fd87c255750759a140ac69a24cf52e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 14:39:00 +0000 Subject: [PATCH 5/7] Bridge Hermes invocation through WSL when only installed there The dashboard runs as a native Windows process, but Hermes' official installer is Bash-only and typically only gets set up inside WSL - a plain PATH lookup for 'hermes' on Windows will never find it there. Add hermes_cli_args(), which checks the native PATH first (so Mac/ Linux/WSL-native setups are unaffected) and falls back to routing through 'wsl -e bash -lc' (a login shell, so PATH additions like uv's ~/.local/bin are sourced) only when hermes isn't found natively but wsl.exe is available. Wire both the chat/dispatch invocation and the agent-health check through it, replacing the plain shutil.which check that always reported Hermes offline in this setup. --- server.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index 1539908..b68d2a3 100644 --- a/server.py +++ b/server.py @@ -8,6 +8,7 @@ import asyncio import json import os import re +import shlex import shutil import signal import subprocess @@ -131,14 +132,32 @@ def append_audit(entry: dict): # ─── Agent Discovery (instant filesystem checks) ──────────────────── +def hermes_cli_args(*args: str) -> list: + """Build the command to invoke Hermes, bridging through WSL if it's only installed there. + + The dashboard commonly runs as a native Windows process while Hermes (whose official + installer is Bash-only) lives inside WSL - a plain PATH lookup on Windows will never find it. + """ + if shutil.which("hermes") is not None or shutil.which("wsl") is None: + return ["hermes", *args] + quoted = " ".join(shlex.quote(a) for a in args) + return ["wsl", "-e", "bash", "-lc", f"hermes {quoted}"] + +def hermes_available() -> bool: + try: + r = subprocess.run(hermes_cli_args("--version"), capture_output=True, text=True, timeout=10) + return r.returncode == 0 + except Exception: + return False + def check_agent(name: str) -> dict: - """Instant filesystem-based check. No subprocess needed.""" + """Filesystem-based check for opencode/gemini; hermes needs a real subprocess since it may live inside WSL.""" try: if name == "opencode": exists = shutil.which("opencode") is not None status = "online" if exists else "offline" elif name == "hermes": - exists = shutil.which("hermes") is not None + exists = hermes_available() status = "online" if exists else "offline" elif name == "gemini": # Gemini has valid OAuth tokens logged in @@ -588,7 +607,7 @@ def execute_agent(agent: str, message: str) -> str: elif agent == "hermes": try: - code, out, err = run_cli(["hermes", "chat", "-q", message], timeout=180) + code, out, err = run_cli(hermes_cli_args("chat", "-q", message), timeout=180) except subprocess.TimeoutExpired: return f"⏱ Hermes timed out.\n\nThe model took too long to respond. Try a shorter query or check your OpenRouter rate limits.\n\n**Message:** {message[:100]}" if code == 0: From d36785c7eef65de9da7706f15e779bfff60e6966 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 17:22:41 +0000 Subject: [PATCH 6/7] Make Skills Hub context files editable and add skill creation Two real gaps: the Skills Hub had no way to create a new skill (with a SKILL.md) at all - the only 'Install' flow was the Plugin registry, which just records a name in a JSON file, not an actual skill folder. And the Context Files panel was read-only, just listing filenames with no way to view, edit, add, or delete their contents. Adds POST /api/skills (create, with SKILL.md content), PUT /api/skills/{name} (edit SKILL.md), and GET/PUT/DELETE /api/skills/{name}/context/{filename} for context files - all validated through the same regex-allowlist + resolved-path containment pattern already used for kanban tasks. Dashboard gets a '+ New Skill' button, an editable SKILL.md view, and per-file edit/delete plus 'Add File' in the Context Files panel. --- dashboard/api.js | 5 ++ dashboard/pages/skills.js | 150 ++++++++++++++++++++++++++++++++++++-- server.py | 83 ++++++++++++++++++++- 3 files changed, 229 insertions(+), 9 deletions(-) diff --git a/dashboard/api.js b/dashboard/api.js index 17a9704..a54e4ad 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -34,6 +34,11 @@ const api = { getSkill: (name) => api.get(`/api/skills/${encodeURIComponent(name)}`), runSkill: (name, input = '', agent = 'auto') => api.post(`/api/skills/${encodeURIComponent(name)}/run`, { input, agent }), getSkillEval: (name) => api.get(`/api/skills/${encodeURIComponent(name)}/eval`), + createSkill: (name, skillMd) => api.post('/api/skills', { name, skill_md: skillMd }), + updateSkill: (name, skillMd) => api.put(`/api/skills/${encodeURIComponent(name)}`, { skill_md: skillMd }), + getSkillContextFile: (name, filename) => api.get(`/api/skills/${encodeURIComponent(name)}/context/${encodeURIComponent(filename)}`), + putSkillContextFile: (name, filename, content) => api.put(`/api/skills/${encodeURIComponent(name)}/context/${encodeURIComponent(filename)}`, { content }), + deleteSkillContextFile: (name, filename) => api.del(`/api/skills/${encodeURIComponent(name)}/context/${encodeURIComponent(filename)}`), getJobs: () => api.get('/api/scheduler/jobs'), createJob: (job) => api.post('/api/scheduler/jobs', job), deleteJob: (id) => api.del(`/api/scheduler/jobs/${encodeURIComponent(id)}`), diff --git a/dashboard/pages/skills.js b/dashboard/pages/skills.js index 42e719f..84c6122 100644 --- a/dashboard/pages/skills.js +++ b/dashboard/pages/skills.js @@ -8,6 +8,7 @@ async function renderSkills() {
+
@@ -93,6 +94,8 @@ async function showSkillDetail(name) { const lastScore = scores.length > 0 ? scores[scores.length - 1] : null; const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null; + window._currentSkillName = name; + detail.innerHTML = `
@@ -100,8 +103,11 @@ async function showSkillDetail(name) {
-
πŸ“„ SKILL.md
-
${escapeHtml(skill.skill || 'No SKILL.md')}
+
+ πŸ“„ SKILL.md + +
+
${escapeHtml(skill.skill || 'No SKILL.md')}
πŸ“– Learnings
@@ -120,10 +126,20 @@ async function showSkillDetail(name) { ` : '
No evaluation scores yet
'}
-
πŸ“ Context Files
- ${skill.context && skill.context.length > 0 - ? `
${skill.context.map(f => `${f}`).join('')}
` - : '
No context files
'} +
+ πŸ“ Context Files + +
+
+ ${skill.context && skill.context.length > 0 + ? skill.context.map(f => ` +
+ ${escapeHtml(f)} + + +
`).join('') + : '
No context files
'} +
${skill.eval && skill.eval.criteria ? `
Eval Criteria:
${skill.eval.criteria.map(c => `${c}`).join('')}
` : ''}
@@ -195,3 +211,125 @@ async function executeSkillRun(name) { if (runBtn) { runBtn.textContent = 'β–Ά Run'; runBtn.disabled = false; } } } + +function showAddSkill() { + showModal('New Skill', ` +
+ + +
Letters, numbers, dashes and underscores only.
+
+
+ + +
+ `, ` + + + `); +} + +async function submitNewSkill() { + const name = document.getElementById('newSkillName').value.trim(); + const skillMd = document.getElementById('newSkillMd').value; + if (!name) { showToast('Skill name is required', 'error'); return; } + try { + await api.createSkill(name, skillMd); + showToast('Skill created!', 'success'); + closeModal(); + await renderSkills(); + showSkillDetail(name); + } catch (err) { + showToast('Failed to create skill: ' + err.message, 'error'); + } +} + +function editSkillMd(name) { + const view = document.getElementById('skillMdView'); + const current = view ? view.textContent : ''; + view.outerHTML = ` + +
+ + +
+ `; +} + +async function saveSkillMd(name) { + const content = document.getElementById('skillMdEdit').value; + try { + await api.updateSkill(name, content); + showToast('SKILL.md saved', 'success'); + showSkillDetail(name); + } catch (err) { + showToast('Failed to save: ' + err.message, 'error'); + } +} + +function addSkillContextFile(name) { + showModal('Add Context File', ` +
+ + +
+
+ + +
+ `, ` + + + `); +} + +async function submitNewContextFile(name) { + const filename = document.getElementById('newContextFilename').value.trim(); + const content = document.getElementById('newContextContent').value; + if (!filename) { showToast('File name is required', 'error'); return; } + try { + await api.putSkillContextFile(name, filename, content); + showToast('Context file added', 'success'); + closeModal(); + showSkillDetail(name); + } catch (err) { + showToast('Failed to add file: ' + err.message, 'error'); + } +} + +async function editSkillContextFile(name, filename) { + try { + const file = await api.getSkillContextFile(name, filename); + showModal(`Edit: ${filename}`, ` + + `, ` + + + `); + } catch (err) { + showToast('Failed to load file: ' + err.message, 'error'); + } +} + +async function saveSkillContextFile(name, filename) { + const content = document.getElementById('editContextContent').value; + try { + await api.putSkillContextFile(name, filename, content); + showToast('File saved', 'success'); + closeModal(); + showSkillDetail(name); + } catch (err) { + showToast('Failed to save: ' + err.message, 'error'); + } +} + +async function deleteSkillContextFile(name, filename) { + if (!confirm(`Delete "${filename}"?`)) return; + try { + await api.deleteSkillContextFile(name, filename); + showToast('File deleted', 'info'); + showSkillDetail(name); + } catch (err) { + showToast('Failed to delete: ' + err.message, 'error'); + } +} diff --git a/server.py b/server.py index b68d2a3..581a4e3 100644 --- a/server.py +++ b/server.py @@ -88,6 +88,16 @@ class SkillRunRequest(BaseModel): input: Optional[str] = "" agent: Optional[str] = "auto" +class SkillCreate(BaseModel): + name: str + skill_md: str = "" + +class SkillUpdate(BaseModel): + skill_md: str + +class SkillContextFileWrite(BaseModel): + content: str = "" + class ScheduleJobRequest(BaseModel): name: str skill: str @@ -212,6 +222,27 @@ def update_brain_file(file_name: str, data: BrainUpdate): # ─── Routes: Skills ─────────────────────────────────────────────── +SKILL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") +SKILL_CONTEXT_FILENAME_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$") + +def skill_dir_path(name: str) -> Path: + if not SKILL_NAME_RE.fullmatch(name or ""): + raise HTTPException(400, "Invalid skill name") + base = (BASE_DIR / "skills").resolve() + candidate = (base / name).resolve() + if candidate.parent != base: + raise HTTPException(400, "Invalid skill name") + return candidate + +def skill_context_file_path(name: str, filename: str) -> Path: + if not SKILL_CONTEXT_FILENAME_RE.fullmatch(filename or ""): + raise HTTPException(400, "Invalid file name") + context_dir = (skill_dir_path(name) / "context").resolve() + candidate = (context_dir / filename).resolve() + if candidate.parent != context_dir: + raise HTTPException(400, "Invalid file name") + return candidate + @app.get("/api/skills") def list_skills(): skills = [] @@ -238,7 +269,7 @@ def list_skills(): @app.get("/api/skills/{name}") def get_skill(name: str): - path = BASE_DIR / "skills" / name + path = skill_dir_path(name) if not path.exists(): raise HTTPException(404, "Skill not found") return { @@ -250,9 +281,55 @@ def get_skill(name: str): "context": [f.name for f in (path / "context").iterdir()] if (path / "context").exists() else [], } +@app.post("/api/skills") +def create_skill(data: SkillCreate): + path = skill_dir_path(data.name) + if path.exists(): + raise HTTPException(409, "Skill already exists") + path.mkdir(parents=True) + (path / "SKILL.md").write_text(data.skill_md, encoding="utf-8") + append_audit({"action": "skill_created", "skill": data.name}) + return {"name": data.name} + +@app.put("/api/skills/{name}") +def update_skill(name: str, data: SkillUpdate): + path = skill_dir_path(name) + if not path.exists(): + raise HTTPException(404, "Skill not found") + (path / "SKILL.md").write_text(data.skill_md, encoding="utf-8") + append_audit({"action": "skill_updated", "skill": name}) + return {"status": "ok"} + +@app.get("/api/skills/{name}/context/{filename}") +def get_skill_context_file(name: str, filename: str): + path = skill_context_file_path(name, filename) + if not path.exists(): + raise HTTPException(404, "File not found") + return {"filename": filename, "content": read_file(path)} + +@app.put("/api/skills/{name}/context/{filename}") +def put_skill_context_file(name: str, filename: str, data: SkillContextFileWrite): + skill_path = skill_dir_path(name) + if not skill_path.exists(): + raise HTTPException(404, "Skill not found") + path = skill_context_file_path(name, filename) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(data.content, encoding="utf-8") + append_audit({"action": "skill_context_updated", "skill": name, "file": filename}) + return {"status": "ok"} + +@app.delete("/api/skills/{name}/context/{filename}") +def delete_skill_context_file(name: str, filename: str): + path = skill_context_file_path(name, filename) + if not path.exists(): + raise HTTPException(404, "File not found") + path.unlink() + append_audit({"action": "skill_context_deleted", "skill": name, "file": filename}) + return {"status": "deleted"} + @app.post("/api/skills/{name}/run") def run_skill(name: str, req: Optional[SkillRunRequest] = None): - path = BASE_DIR / "skills" / name + path = skill_dir_path(name) if not path.exists(): raise HTTPException(404, "Skill not found") @@ -335,7 +412,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): @app.get("/api/skills/{name}/eval") def get_skill_eval(name: str): - path = BASE_DIR / "skills" / name / "score-history.json" + path = skill_dir_path(name) / "score-history.json" if not path.exists(): return {"scores": []} return {"scores": json.loads(path.read_text())} From 2d131bfb8a9213582ad692e04b73dd5adf8c9e79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 23:03:44 +0000 Subject: [PATCH 7/7] Verify native hermes is actually the right agent before trusting it hermes_cli_args() previously trusted any 'hermes' found on native PATH without checking what it actually was. Windows machines can have an unrelated tool also named 'hermes' (softwarepub/HERMES, an academic software-publication tool with harvest/process/curate/deposit subcommands - confirmed to be what was actually on this machine's PATH), which would silently get used instead of the real NousResearch agent installed in WSL, producing the misleading 'Hermes needs setup' message. Now check that a native 'hermes' actually exposes the agent's 'chat' subcommand before using it directly, falling back to the WSL bridge otherwise. --- server.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index 581a4e3..b106ccd 100644 --- a/server.py +++ b/server.py @@ -142,16 +142,29 @@ def append_audit(entry: dict): # ─── Agent Discovery (instant filesystem checks) ──────────────────── +def _cli_has_subcommand(base_args: list, subcommand: str) -> bool: + try: + r = subprocess.run([*base_args, "--help"], capture_output=True, text=True, timeout=10) + return subcommand in ((r.stdout or "") + (r.stderr or "")) + except Exception: + return False + def hermes_cli_args(*args: str) -> list: - """Build the command to invoke Hermes, bridging through WSL if it's only installed there. + """Build the command to invoke Hermes, bridging through WSL if the real agent only lives there. The dashboard commonly runs as a native Windows process while Hermes (whose official - installer is Bash-only) lives inside WSL - a plain PATH lookup on Windows will never find it. + installer is Bash-only) lives inside WSL - a plain PATH lookup on Windows will never find it + there. Windows machines can also have an unrelated tool also named 'hermes' on PATH (e.g. the + academic softwarepub/HERMES metadata-publishing project, which coincidentally shares the name), + so don't just trust that a native 'hermes' is the right one - confirm it exposes the + NousResearch agent's `chat` subcommand before using it, falling back to WSL otherwise. """ - if shutil.which("hermes") is not None or shutil.which("wsl") is None: + if shutil.which("hermes") is not None and _cli_has_subcommand(["hermes"], "chat"): return ["hermes", *args] - quoted = " ".join(shlex.quote(a) for a in args) - return ["wsl", "-e", "bash", "-lc", f"hermes {quoted}"] + if shutil.which("wsl") is not None: + quoted = " ".join(shlex.quote(a) for a in args) + return ["wsl", "-e", "bash", "-lc", f"hermes {quoted}"] + return ["hermes", *args] def hermes_available() -> bool: try: