diff --git a/dashboard/api.js b/dashboard/api.js index b2bf437..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)}`), @@ -52,12 +57,10 @@ 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)}`), + 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/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/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/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 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 08c2427..c87d3f0 100644 --- a/server.py +++ b/server.py @@ -4,10 +4,13 @@ Agentic OS β€” FastAPI Backend Multi-agent orchestration server for opencode, Hermes, Gemini CLI """ import argparse +import asyncio import json import os import re +import shlex import shutil +import signal import subprocess import tarfile import threading @@ -17,7 +20,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 @@ -85,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 @@ -101,9 +114,6 @@ class ChatRequest(BaseModel): agent: str message: str -class TerminalRunRequest(BaseModel): - command: str - # ─── Helper Functions ───────────────────────────────────────────── def read_file(path: Path): @@ -140,20 +150,6 @@ def load_json_file(path: Path, default=_MISSING, best_effort=False): return default raise HTTPException(500, f"Failed to read {path.name}: {e}") -def skill_dir(name: str) -> Path: - """Resolve a user-supplied skill name to its directory. - - The name is matched against the actual directory entries rather than used to - build a path, so traversal input (``..``, ``/``) can never escape the skills - directory. - """ - base = BASE_DIR / "skills" - if base.exists(): - for entry in base.iterdir(): - if entry.is_dir() and entry.name == name: - return entry - raise HTTPException(404, "Skill not found") - def list_dir(path: Path): if not path.exists(): return [] @@ -177,12 +173,43 @@ 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 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 + 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 and _cli_has_subcommand(["hermes"], "chat"): + return ["hermes", *args] + 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: + 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.""" if name == "opencode": status = "online" if shutil.which("opencode") is not None else "offline" elif name == "hermes": - status = "online" if shutil.which("hermes") is not None else "offline" + status = "online" if hermes_available() else "offline" elif name == "gemini": exists = shutil.which("gemini") is not None # Gemini needs a valid OAuth token on disk to be usable. @@ -241,6 +268,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 = [] @@ -261,7 +309,7 @@ def list_skills(): @app.get("/api/skills/{name}") def get_skill(name: str): - path = skill_dir(name) + path = skill_dir_path(name) if not path.exists(): raise HTTPException(404, "Skill not found") return { @@ -273,9 +321,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 = skill_dir(name) + path = skill_dir_path(name) if not path.exists(): raise HTTPException(404, "Skill not found") @@ -358,7 +452,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): @app.get("/api/skills/{name}/eval") def get_skill_eval(name: str): - path = skill_dir(name) / "score-history.json" + path = skill_dir_path(name) / "score-history.json" return {"scores": load_json_file(path, default=[])} # ─── Routes: Scheduler ──────────────────────────────────────────── @@ -635,7 +729,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: @@ -713,39 +807,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: - 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 @@ -930,6 +1103,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: