Merge pull request #10 from zumayaaustin-creator/claude/agentic-os-setup-5ubuao
* Run dashboard Terminal commands through PowerShell on Windows
subprocess.run(..., shell=True) uses cmd.exe on Windows by default,
which doesn't understand PowerShell syntax like $env:VAR or
$env:USERPROFILE - commands using it failed with 'cannot find the
file specified' since cmd took it as a literal filename. Invoke
powershell.exe explicitly on Windows instead; POSIX behavior is
unchanged.
* Replace command-runner Terminal with a real interactive PTY
The previous Terminal ran one command at a time via subprocess.run
and returned its output - it couldn't run interactive programs
(colors, live input, TUIs like gemini's chat/auth flow), which is
what a terminal actually needs to do.
Backend: new /ws/terminal WebSocket endpoint spawns a real shell
attached to a pseudo-terminal (stdlib pty on POSIX, pywinpty/ConPTY
on Windows) and streams raw I/O bidirectionally, with resize support.
Replaces the old POST /api/terminal/run and GET /api/terminal/session
endpoints entirely.
Frontend: terminal.js now loads xterm.js + the fit addon from CDN
and renders a real terminal emulator wired to the WebSocket, instead
of a scrollback div with a single input line.
Verified on this Linux sandbox via raw WebSocket tests: shell spawns
correctly, commands execute and echo real output, resize propagates
to the PTY (confirmed via ), and closing the connection
cleanly kills the shell process with no orphans (interactive bash
ignores SIGTERM by default, so cleanup uses SIGKILL). Could not
visually verify the xterm.js browser rendering in this sandbox since
its egress policy blocks the CDN (cdn.jsdelivr.net) outright - same
CDN this app already uses for chart.js, so expected to work on a
normal machine; please confirm on Windows.
* Remove demo/sample Kanban tasks
These were placeholder seed data shipped with the repo (a demo
'Fix login bug' task and a 'Test kanban task' with a canned
'Waiting for API review' block reason) - not real tasks. Clearing
them so the board starts empty.
* Add missing DELETE /api/kanban/tasks/{id} endpoint
The Kanban detail modal's Delete button called api.deleteKanbanTask(),
which didn't exist on the client, and there was no backend route for
it either - clicking Delete just threw 'api.deleteKanbanTask is not
a function'. Add both the client method and the backend endpoint.
* Bridge Hermes invocation through WSL when only installed there
The dashboard runs as a native Windows process, but Hermes' official
installer is Bash-only and typically only gets set up inside WSL -
a plain PATH lookup for 'hermes' on Windows will never find it there.
Add hermes_cli_args(), which checks the native PATH first (so Mac/
Linux/WSL-native setups are unaffected) and falls back to routing
through 'wsl -e bash -lc' (a login shell, so PATH additions like
uv's ~/.local/bin are sourced) only when hermes isn't found natively
but wsl.exe is available. Wire both the chat/dispatch invocation and
the agent-health check through it, replacing the plain shutil.which
check that always reported Hermes offline in this setup.
* Make Skills Hub context files editable and add skill creation
Two real gaps: the Skills Hub had no way to create a new skill (with
a SKILL.md) at all - the only 'Install' flow was the Plugin registry,
which just records a name in a JSON file, not an actual skill folder.
And the Context Files panel was read-only, just listing filenames
with no way to view, edit, add, or delete their contents.
Adds POST /api/skills (create, with SKILL.md content), PUT
/api/skills/{name} (edit SKILL.md), and GET/PUT/DELETE
/api/skills/{name}/context/{filename} for context files - all
validated through the same regex-allowlist + resolved-path
containment pattern already used for kanban tasks. Dashboard gets a
'+ New Skill' button, an editable SKILL.md view, and per-file
edit/delete plus 'Add File' in the Context Files panel.
* Verify native hermes is actually the right agent before trusting it
hermes_cli_args() previously trusted any 'hermes' found on native PATH
without checking what it actually was. Windows machines can have an
unrelated tool also named 'hermes' (softwarepub/HERMES, an academic
software-publication tool with harvest/process/curate/deposit
subcommands - confirmed to be what was actually on this machine's
PATH), which would silently get used instead of the real NousResearch
agent installed in WSL, producing the misleading 'Hermes needs setup'
message.
Now check that a native 'hermes' actually exposes the agent's 'chat'
subcommand before using it directly, falling back to the WSL bridge
otherwise.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
commit
71650cbc0b
|
|
@ -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"
|
||||
|
|
|
|||
270
server.py
270
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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue