Merge origin/main into error-handling branch; standardize on skill_dir_path

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
zumayaaustin 2026-07-09 00:09:34 +00:00
commit 7f0c104aea
8 changed files with 490 additions and 204 deletions

View File

@ -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 }),

View File

@ -8,6 +8,7 @@ async function renderSkills() {
</div>
<div class="btn-group">
<input id="skillFilter" class="form-input" style="width:200px" placeholder="Filter skills..." oninput="filterSkills()">
<button class="btn btn-primary" onclick="showAddSkill()">+ New Skill</button>
</div>
</div>
<div class="tabs" id="skillTabs">
@ -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 = `
<div style="margin-bottom:16px">
<button class="btn btn-ghost" onclick="backToSkills()"> Back to Skills</button>
@ -100,8 +103,11 @@ async function showSkillDetail(name) {
</div>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><span class="card-title">📄 SKILL.md</span></div>
<pre style="max-height:400px;overflow:auto;font-size:12px">${escapeHtml(skill.skill || 'No SKILL.md')}</pre>
<div class="card-header">
<span class="card-title">📄 SKILL.md</span>
<button class="btn btn-sm btn-ghost" style="margin-left:auto" onclick="editSkillMd('${name}')"> Edit</button>
</div>
<pre id="skillMdView" style="max-height:400px;overflow:auto;font-size:12px">${escapeHtml(skill.skill || 'No SKILL.md')}</pre>
</div>
<div class="card">
<div class="card-header"><span class="card-title">📖 Learnings</span></div>
@ -120,10 +126,20 @@ async function showSkillDetail(name) {
` : '<div style="color:var(--text-muted);font-size:13px">No evaluation scores yet</div>'}
</div>
<div class="card">
<div class="card-header"><span class="card-title">📁 Context Files</span></div>
${skill.context && skill.context.length > 0
? `<div style="display:flex;flex-wrap:wrap;gap:6px">${skill.context.map(f => `<span class="badge badge-info">${f}</span>`).join('')}</div>`
: '<div style="color:var(--text-muted);font-size:13px">No context files</div>'}
<div class="card-header">
<span class="card-title">📁 Context Files</span>
<button class="btn btn-sm btn-ghost" style="margin-left:auto" onclick="addSkillContextFile('${name}')">+ Add File</button>
</div>
<div id="skillContextList">
${skill.context && skill.context.length > 0
? skill.context.map(f => `
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:13px">
<span style="flex:1">${escapeHtml(f)}</span>
<button class="btn btn-sm btn-ghost" onclick="editSkillContextFile('${name}','${escapeHtml(f)}')"></button>
<button class="btn btn-sm btn-ghost" style="color:var(--red)" onclick="deleteSkillContextFile('${name}','${escapeHtml(f)}')">🗑</button>
</div>`).join('')
: '<div style="color:var(--text-muted);font-size:13px">No context files</div>'}
</div>
${skill.eval && skill.eval.criteria ? `<div style="margin-top:12px"><strong style="font-size:12px">Eval Criteria:</strong><div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">${skill.eval.criteria.map(c => `<span class="badge badge-accent">${c}</span>`).join('')}</div></div>` : ''}
</div>
</div>
@ -195,3 +211,125 @@ async function executeSkillRun(name) {
if (runBtn) { runBtn.textContent = '▶ Run'; runBtn.disabled = false; }
}
}
function showAddSkill() {
showModal('New Skill', `
<div class="form-group">
<label class="form-label">Name</label>
<input id="newSkillName" class="form-input" placeholder="e.g., my-custom-skill">
<div class="form-hint">Letters, numbers, dashes and underscores only.</div>
</div>
<div class="form-group">
<label class="form-label">SKILL.md</label>
<textarea id="newSkillMd" class="form-textarea" rows="12" placeholder="# My Custom Skill&#10;&#10;Describe what this skill does and how an agent should perform it..."></textarea>
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitNewSkill()">Create Skill</button>
`);
}
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 = `
<textarea id="skillMdEdit" class="form-textarea" rows="16" style="font-family:var(--font-mono);font-size:12px">${escapeHtml(current)}</textarea>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm btn-primary" onclick="saveSkillMd('${name}')">Save</button>
<button class="btn btn-sm btn-ghost" onclick="showSkillDetail('${name}')">Cancel</button>
</div>
`;
}
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', `
<div class="form-group">
<label class="form-label">File Name</label>
<input id="newContextFilename" class="form-input" placeholder="e.g., reference.md">
</div>
<div class="form-group">
<label class="form-label">Content</label>
<textarea id="newContextContent" class="form-textarea" rows="10"></textarea>
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitNewContextFile('${name}')">Add File</button>
`);
}
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}`, `
<textarea id="editContextContent" class="form-textarea" rows="14">${escapeHtml(file.content)}</textarea>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveSkillContextFile('${name}','${filename}')">Save</button>
`);
} 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');
}
}

View File

@ -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 = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Terminal</h1>
<p class="page-subtitle">Run shell commands directly on this machine</p>
<p class="page-subtitle">A real, interactive shell running on this machine</p>
</div>
<div class="btn-group">
<button class="btn" onclick="clearTerminal()">🗑 Clear</button>
<span id="terminalStatus" class="badge badge-warning">Connecting</span>
</div>
</div>
<div class="terminal-panel" onclick="focusTerminalInput()">
<div id="terminalOutput" class="terminal-output"></div>
<div class="terminal-input-row">
<span class="terminal-prompt" id="terminalPrompt">$</span>
<input id="terminalInput" class="terminal-input" type="text" autocomplete="off" spellcheck="false" onkeydown="handleTerminalKey(event)">
</div>
<div class="terminal-panel">
<div id="xtermContainer" class="terminal-xterm-container"></div>
</div>
`;
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 =
`<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load terminal library</div><div class="empty-state-desc">${escapeHtml(err.message || String(err))}</div></div>`;
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 });
}

View File

@ -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 ─── */

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"

284
server.py
View File

@ -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: