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:
commit
7f0c104aea
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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 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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─── */
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
284
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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue