@@ -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 = `
@@ -195,3 +211,125 @@ async function executeSkillRun(name) {
if (runBtn) { runBtn.textContent = 'βΆ Run'; runBtn.disabled = false; }
}
}
+
+function showAddSkill() {
+ showModal('New Skill', `
+
+ `);
+}
+
+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 41bfe56..b106ccd 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):
@@ -132,14 +142,45 @@ 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."""
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
@@ -194,6 +235,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 = []
@@ -220,7 +282,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 {
@@ -232,9 +294,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")
@@ -317,7 +425,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())}
@@ -589,7 +697,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:
@@ -667,39 +775,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
@@ -869,6 +1056,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: