Merge branch 'main' into codex/update-setup-guidance-and-cli-checks

Resolve install.ps1 conflict by adopting main's complete installer (Resolve-Python,
agent checks, desktop shortcut, browser auto-open) and folding in this branch's
winget install hints and optional agent-CLI reminders. Clean up README duplicate
Python/Node prerequisite rows and duplicate Windows Quick Start left over from the
earlier merge.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
zumayaaustin 2026-07-09 00:08:16 +00:00
commit 2cb6e4b76f
16 changed files with 782 additions and 165 deletions

4
Launch-Dashboard.bat Normal file
View File

@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
powershell -NoLogo -ExecutionPolicy Bypass -File "%~dp0start.ps1"
pause

View File

@ -107,7 +107,7 @@ chmod +x install.sh && ./install.sh
git clone https://github.com/modimihir07/agentic-os.git
cd agentic-os
.\install.ps1
python .\server.py
.\start.ps1
# Open http://127.0.0.1:8080
```
@ -119,15 +119,14 @@ python .\server.py
| Tool | Required? | Install |
|------|-----------|---------|
| Python 3.10+ | ✅ Required | Install from your OS package manager or [python.org](https://www.python.org/downloads/) |
| Node.js 18+ | ⚠ For opencode and Gemini CLI | Install from your OS package manager or [nodejs.org](https://nodejs.org/) |
| Python 3.10+ | ✅ Required | Linux/macOS: install from your package manager or `python.org`. Windows: install from `python.org` and select **Add Python to PATH**. Verify with `python --version` or `py -3 --version`. |
| Node.js 18+ | ⚠ For opencode | `curl -fsSL https://deb.nodesource.com/setup_20.x \| sudo bash - && sudo apt install -y nodejs` |
| Python 3.10+ | ✅ Required | Install from your package manager or [python.org](https://www.python.org/downloads/). Verify with `python3 --version`. |
| Node.js 18+ | ⚠ For opencode and Gemini CLI | `curl -fsSL https://deb.nodesource.com/setup_20.x \| sudo bash - && sudo apt install -y nodejs` |
| opencode | ⚠ For code tasks | `npm install -g @opencode/cli` |
| Hermes Agent | ⚠ For memory/scheduling | Follow the upstream Hermes Agent documentation. If native Windows support is unavailable, use WSL for Hermes. |
| Gemini CLI | ⚠ For Google AI | `npm install -g @google/gemini-cli` |
> ⚠ = Optional — the dashboard starts and core pages work without all agent CLIs installed. Agent-specific chat, routing, health, and skill execution features will show offline or warning status until the relevant CLI is installed and authenticated.
### Windows
| Tool | Required? | Install | Verify / Next Step |
@ -140,16 +139,6 @@ python .\server.py
> ⚠ = Optional — the dashboard works with any subset of installed agents.
### Windows Quick Start
```powershell
git clone https://github.com/modimihir07/agentic-os.git
cd agentic-os
.\install.ps1
.\start.ps1
# Open http://127.0.0.1:8080
```
The PowerShell launchers resolve Python in this order: `py -3.10`, `py -3`, then `python`. For manual commands, prefer `python -m pip install -r requirements.txt` and `python server.py --port 8080` so the same interpreter runs both dependency installation and the server.
> **PowerShell execution policy:** if you use PowerShell scripts, allow local scripts for your user with `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`, or run a single installer invocation with `powershell -ExecutionPolicy Bypass -File .\install.ps1`.

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)}`),
@ -55,6 +60,7 @@ const api = {
// 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 }),
@ -64,6 +70,7 @@ const api = {
linkKanbanTasks: (parentId, childId) => api.post('/api/kanban/links', { parent_id: parentId, child_id: childId }),
unlinkKanbanTasks: (parentId, childId) => api.del(`/api/kanban/links?parent_id=${encodeURIComponent(parentId)}&child_id=${encodeURIComponent(childId)}`),
dispatchKanban: () => api.post('/api/kanban/dispatch', {}),
dispatchKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/dispatch`, {}),
specifyKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/specify`, {}),
decomposeKanbanTask: (id) => api.post(`/api/kanban/tasks/${encodeURIComponent(id)}/decompose`, {}),
// Goals

View File

@ -33,6 +33,7 @@
<div class="sidebar-nav">
<div class="sidebar-section"><div class="sidebar-section-label">Primary</div></div>
<a href="#chat" class="nav-item" data-page="chat"><span class="nav-icon">💬</span><span class="nav-label">AI Chat</span></a>
<a href="#terminal" class="nav-item" data-page="terminal"><span class="nav-icon"></span><span class="nav-label">Terminal</span></a>
<a href="#dashboard" class="nav-item active" data-page="dashboard"><span class="nav-icon"></span><span class="nav-label">Dashboard</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Agents</div></div>
<a href="#skills" class="nav-item" data-page="skills"><span class="nav-icon"></span><span class="nav-label">Skills</span><span class="nav-badge" id="skillCount">0</span></a>

View File

@ -1,5 +1,9 @@
let kanbanData = null;
function getAllKanbanTasks() {
return Object.values(kanbanData?.columns || {}).flat();
}
async function renderKanban() {
const content = document.getElementById('pageContent');
content.innerHTML = `
@ -53,7 +57,7 @@ function renderKanbanBoard() {
if (!board || !kanbanData) return;
const columnsObj = kanbanData.columns || {};
const columns = Object.keys(columnsObj);
const allTasks = kanbanData.tasks || Object.values(columnsObj).flat();
const allTasks = getAllKanbanTasks();
const filterText = (document.getElementById('kanbanFilterInput')?.value || '').toLowerCase();
const filterPriority = document.getElementById('kanbanFilterPriority')?.value || 'all';
const filterCategory = document.getElementById('kanbanFilterCategory')?.value || 'all';
@ -147,7 +151,12 @@ function showAddKanbanTask() {
<div class="form-row">
<div class="form-group">
<label class="form-label">Assigned To</label>
<input class="form-input" id="kanbanAssigned" placeholder="e.g., opencode" style="text-transform:lowercase">
<select class="form-select" id="kanbanAssigned">
<option value="">Unassigned</option>
<option value="opencode">opencode (auto-run)</option>
<option value="hermes">Hermes (auto-run)</option>
<option value="gemini">Gemini CLI (auto-run)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Target Date</label>
@ -206,31 +215,68 @@ async function onKanbanDrop(e, status) {
}
}
function showKanbanDetail(id) {
const task = kanbanData?.tasks?.find(t => t.id === id);
const KANBAN_AGENTS = ['opencode', 'hermes', 'gemini'];
function closeKanbanDetailModal() {
if (window._kanbanDetailPoll) { clearInterval(window._kanbanDetailPoll); window._kanbanDetailPoll = null; }
closeModal();
}
async function showKanbanDetail(id) {
const task = getAllKanbanTasks().find(t => t.id === id);
if (!task) return;
renderKanbanDetailModal(task);
if (window._kanbanDetailPoll) clearInterval(window._kanbanDetailPoll);
if (task.status === 'in_progress') {
window._kanbanDetailPoll = setInterval(async () => {
try {
const fresh = await api.getKanbanTask(id);
if (fresh.status !== 'in_progress') {
clearInterval(window._kanbanDetailPoll);
window._kanbanDetailPoll = null;
renderKanban();
}
if (document.getElementById('kanbanDetailModal')) renderKanbanDetailModal(fresh);
} catch {}
}, 3000);
}
}
function renderKanbanDetailModal(task) {
const modal = document.getElementById('modalContainer');
const canDispatch = KANBAN_AGENTS.includes(task.assignee) && task.status !== 'in_progress' && task.status !== 'done';
const comments = task.comments || [];
modal.innerHTML = `
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal" style="max-width:560px">
<div class="modal-overlay" onclick="if(event.target===this)closeKanbanDetailModal()">
<div class="modal" id="kanbanDetailModal" style="max-width:560px">
<div class="modal-header">
<div class="modal-title">${escapeHtml(task.title)}</div>
<button class="modal-close" onclick="closeModal()"></button>
<button class="modal-close" onclick="closeKanbanDetailModal()"></button>
</div>
<div class="modal-body">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
<span class="badge badge-${task.status === 'done' ? 'success' : task.status === 'in_progress' ? 'info' : 'warning'}">${task.status}</span>
<span class="kanban-priority priority-${task.priority || 'medium'}">${task.priority}</span>
${task.status === 'blocked' ? `<span class="badge badge-danger">🚫 Blocked</span>` : ''}
${task.status === 'in_progress' ? `<span class="badge badge-info">🔄 Agent running…</span>` : ''}
</div>
${task.body ? `<div style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">${escapeHtml(task.body)}</div>` : ''}
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px;font-size:12px">
${task.assignee ? `<span>👤 <strong>${escapeHtml(task.assignee)}</strong></span>` : ''}
${task.assignee ? `<span>👤 <strong>${escapeHtml(task.assignee)}</strong>${KANBAN_AGENTS.includes(task.assignee) ? ' 🤖' : ''}</span>` : ''}
<span>📅 <strong>${task.created || 'N/A'}</strong></span>
${task.completed_at ? `<span>✅ <strong>${task.completed_at}</strong></span>` : ''}
</div>
${task.block_reason ? `<div style="margin-bottom:12px;font-size:12px;color:var(--red)">🚫 ${escapeHtml(task.block_reason)}</div>` : ''}
${comments.length ? `
<div style="margin-bottom:12px">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-muted);margin-bottom:6px">Activity</div>
${comments.map(c => `<div style="padding:8px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:12px;white-space:pre-wrap;margin-bottom:6px">${escapeHtml(c.message)}</div>`).join('')}
</div>
` : ''}
<div style="display:flex;gap:4px;flex-wrap:wrap">
${task.status !== 'done' ? `<button class="btn btn-sm btn-primary" onclick="completeKanbanTask('${task.id}')">✅ Mark Done</button>` : ''}
${canDispatch ? `<button class="btn btn-sm btn-primary" onclick="dispatchKanbanTask('${task.id}')">🤖 Dispatch to ${escapeHtml(task.assignee)}</button>` : ''}
${task.status !== 'done' ? `<button class="btn btn-sm btn-ghost" onclick="completeKanbanTask('${task.id}')">✅ Mark Done</button>` : ''}
${task.status !== 'blocked' ? `<button class="btn btn-sm btn-ghost" onclick="blockKanbanTask('${task.id}')">🚫 Block</button>` : `<button class="btn btn-sm btn-ghost" onclick="unblockKanbanTask('${task.id}')">🔓 Unblock</button>`}
<button class="btn btn-sm btn-ghost" onclick="deleteKanbanTask('${task.id}')" style="color:var(--red)">🗑 Delete</button>
</div>
@ -240,6 +286,18 @@ function showKanbanDetail(id) {
`;
}
async function dispatchKanbanTask(id) {
try {
await api.dispatchKanbanTask(id);
showToast('Task dispatched to agent 🤖', 'success');
await loadKanbanData();
const fresh = getAllKanbanTasks().find(t => t.id === id);
if (fresh) showKanbanDetail(id);
} catch (err) {
showToast('Failed to dispatch: ' + err.message, 'error');
}
}
async function completeKanbanTask(id) {
try {
await api.completeKanbanTask(id);

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');
}
}

120
dashboard/pages/terminal.js Normal file
View File

@ -0,0 +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">A real, interactive shell running on this machine</p>
</div>
<div class="btn-group">
<span id="terminalStatus" class="badge badge-warning">Connecting</span>
</div>
</div>
<div class="terminal-panel">
<div id="xtermContainer" class="terminal-xterm-container"></div>
</div>
`;
closeTerminalSession();
try {
await loadXterm();
} catch (err) {
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

@ -821,6 +821,21 @@ pre {
.chat-message { max-width: 95%; }
}
/* Terminal */
.terminal-panel {
flex: 1; display: flex; flex-direction: column;
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);
}
.terminal-xterm-container {
flex: 1; min-height: 0; padding: 8px;
}
.terminal-xterm-container .xterm { height: 100%; }
/* ─── v0.2.0 UI Modernization ─── */
/* Glass card variant */

View File

@ -123,6 +123,7 @@ const PAGE_TITLES = {
settings: { title: 'Settings', breadcrumb: 'Configuration' },
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
chat: { title: 'AI Chat', breadcrumb: 'Multi-agent terminal' },
terminal: { title: 'Terminal', breadcrumb: 'Local shell access' },
kanban: { title: 'Kanban Board', breadcrumb: 'Multi-agent task management' },
goals: { title: 'Goals', breadcrumb: 'Project targets and progress' },
journal: { title: 'Journal', breadcrumb: 'Daily entries and notes' },

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

@ -1,81 +1,5 @@
$ErrorActionPreference = "Stop"
Write-Host "=== Agentic OS Windows Installer ==="
Write-Host ""
function Test-Command {
param([string]$Name)
return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
}
# Check Python
if (Test-Command "python") {
Write-Host "Python: $(python --version)"
} elseif (Test-Command "py") {
Write-Host "Python: $(py --version)"
} else {
Write-Host "ERROR: Python 3.10+ required. Install from https://www.python.org/downloads/ or winget install Python.Python.3.12"
exit 1
}
# Install Python dependencies
Write-Host "Installing Python dependencies..."
if (Test-Command "python") {
python -m pip install -r requirements.txt --quiet
} else {
py -m pip install -r requirements.txt --quiet
}
# Check Node.js (for opencode and Gemini CLI)
if (Test-Command "node") {
Write-Host "Node.js: $(node --version)"
} else {
Write-Host "WARNING: Node.js not found. opencode and Gemini CLI require Node.js 18+."
Write-Host " Install from https://nodejs.org/ or run: winget install OpenJS.NodeJS.LTS"
}
# Check opencode
if (Test-Command "opencode") {
$opencodeVersion = (& opencode --version 2>$null)
if ($LASTEXITCODE -eq 0 -and $opencodeVersion) {
Write-Host "opencode: $opencodeVersion"
} else {
Write-Host "opencode: installed"
}
} else {
Write-Host "WARNING: opencode not found. Install via: npm install -g @opencode/cli"
}
# Check Gemini CLI
if (Test-Command "gemini") {
Write-Host "Gemini CLI: found"
} else {
Write-Host "WARNING: Gemini CLI not found. Install via: npm install -g @google/gemini-cli"
}
# Check Hermes Agent
if (Test-Command "hermes") {
Write-Host "Hermes Agent: found"
} else {
Write-Host "WARNING: Hermes Agent not found. Native Windows installer support is not confirmed by this project."
Write-Host " Check upstream Hermes Agent documentation for current Windows support."
Write-Host " If no native installer is available, use WSL for Hermes Agent."
}
# Create required directories
New-Item -ItemType Directory -Force -Path "backups", "audit" | Out-Null
# Initialize git if not already initialized
if (-not (Test-Path ".git")) {
Write-Host "Initializing git repository..."
git init | Out-Null
if (-not (Test-Path ".gitignore")) {
New-Item -ItemType File -Path ".gitignore" | Out-Null
}
$gitignore = Get-Content ".gitignore" -ErrorAction SilentlyContinue
foreach ($entry in @("audit/*", "backups/*.tar.gz", "data/settings.json")) {
if ($gitignore -notcontains $entry) {
Add-Content ".gitignore" $entry
Write-Host "=== Agentic OS Installer ==="
Write-Host ""
@ -98,7 +22,7 @@ function Resolve-Python {
}
}
throw "Python 3.10+ is required. Install it from https://www.python.org/downloads/ and check 'Add Python to PATH'."
throw "Python 3.10+ is required. Install it from https://www.python.org/downloads/ (check 'Add Python to PATH') or run: winget install Python.Python.3.12"
}
$python = Resolve-Python
@ -110,11 +34,11 @@ Write-Host "Python: $pythonVersion"
Write-Host "Installing Python dependencies..."
& $pythonCommand @pythonArgs -m pip install -r requirements.txt --quiet
# Check Node.js (for opencode)
# Check Node.js (for opencode and Gemini CLI)
if (Get-Command node -ErrorAction SilentlyContinue) {
Write-Host "Node.js: $(node --version)"
} else {
Write-Warning "Node.js not found. opencode requires Node 18+. Install from https://nodejs.org/."
Write-Warning "Node.js not found. opencode and Gemini CLI require Node 18+. Install from https://nodejs.org/ or run: winget install OpenJS.NodeJS.LTS"
}
# Check opencode
@ -129,7 +53,7 @@ if (Get-Command opencode -ErrorAction SilentlyContinue) {
if (Get-Command hermes -ErrorAction SilentlyContinue) {
Write-Host "Hermes: found"
} else {
Write-Warning "Hermes Agent not found. See the Hermes Agent documentation for Windows installation guidance."
Write-Warning "Hermes Agent not found. Native Windows support is not confirmed by this project - check the upstream Hermes Agent documentation, and use WSL if no native installer is available."
}
# Check Gemini CLI
@ -151,17 +75,33 @@ if (-not (Test-Path .git)) {
}
}
$launcher = Join-Path $PSScriptRoot "Launch-Dashboard.bat"
$desktop = [Environment]::GetFolderPath("Desktop")
if ((Test-Path $launcher) -and (Test-Path $desktop)) {
try {
$shortcutPath = Join-Path $desktop "Agentic OS Dashboard.lnk"
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $launcher
$shortcut.WorkingDirectory = $PSScriptRoot
$shortcut.Description = "Launch the Agentic OS dashboard"
$shortcut.Save()
Write-Host "Desktop shortcut created: $shortcutPath"
} catch {
Write-Warning "Could not create Desktop shortcut: $_"
}
}
Write-Host ""
Write-Host "=== Installation complete! ==="
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Edit data/settings.json with your API keys"
Write-Host " 2. Run python .\server.py to launch the dashboard"
Write-Host " 3. Open http://127.0.0.1:8080 in your browser"
Write-Host " 2. Double-click the 'Agentic OS Dashboard' shortcut on your Desktop"
Write-Host " (or run .\start.ps1 / .\Launch-Dashboard.bat manually)"
Write-Host " 3. Your browser will open automatically once the server is ready"
Write-Host ""
Write-Host "Optional agent CLI reminders:"
Write-Host " opencode: npm install -g @opencode/cli"
Write-Host " Gemini: npm install -g @google/gemini-cli"
Write-Host " Hermes: Use upstream docs for native Windows support, or WSL if native support is unavailable."
Write-Host " 2. Run .\start.ps1 to launch the dashboard"
Write-Host " 3. Open http://127.0.0.1:8080 in your browser"

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"

359
server.py
View File

@ -4,18 +4,23 @@ 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
import time
import uuid
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
@ -83,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
@ -127,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
@ -189,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 = []
@ -215,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 {
@ -227,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")
@ -312,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())}
@ -584,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:
@ -662,6 +775,119 @@ def chat(req: ChatRequest):
def get_chat_history():
return load_chat_history()
# ─── Routes: Terminal (real interactive PTY over WebSocket) ──────
class PtySession:
"""Wraps a real pseudo-terminal shell process, POSIX (stdlib pty) or Windows (pywinpty)."""
def __init__(self):
self.master_fd = None
self.pid = None
self.winpty_process = None
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
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:
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
# ═══════════════════════════════════════════════════════════════════
@ -735,9 +961,68 @@ def load_kanban_tasks():
tasks.append(json.loads(f.read_text()))
return tasks
KANBAN_ID_RE = re.compile(r"^[0-9a-f]{6,16}$")
def kanban_task_path(task_id: str) -> Path:
"""Resolve a task id to its file path, rejecting anything that isn't a plain generated id."""
if not KANBAN_ID_RE.fullmatch(task_id or ""):
raise HTTPException(400, "Invalid task id")
base = KANBAN_DIR.resolve()
candidate = (base / f"{task_id}.json").resolve()
if candidate.parent != base:
raise HTTPException(400, "Invalid task id")
return candidate
def save_kanban_task(task: dict):
ensure_dir(KANBAN_DIR)
(KANBAN_DIR / f"{task['id']}.json").write_text(json.dumps(task, indent=2))
kanban_task_path(task["id"]).write_text(json.dumps(task, indent=2))
KANBAN_AGENTS = {"opencode", "hermes", "gemini"}
def dispatch_kanban_task(task_id: str):
"""Move a task to in_progress and hand it to its assignee agent in the background."""
path = kanban_task_path(task_id)
if not path.exists():
return
task = json.loads(path.read_text())
if task.get("status") in ("in_progress", "done"):
return
if task.get("assignee") not in KANBAN_AGENTS:
return
task["status"] = "in_progress"
task["updated"] = get_timestamp()
save_kanban_task(task)
append_audit({"action": "kanban_task_dispatched", "task_id": task_id, "agent": task["assignee"]})
threading.Thread(target=_run_kanban_agent, args=(task_id,), daemon=True).start()
def _run_kanban_agent(task_id: str):
path = kanban_task_path(task_id)
if not path.exists():
return
task = json.loads(path.read_text())
agent = task.get("assignee")
prompt = task["title"] if not task.get("body") else f"{task['title']}\n\n{task['body']}"
response = execute_agent(agent, prompt)
failed = response.startswith(("", "", "Unknown agent"))
task = json.loads(path.read_text()) # reload in case it changed while the agent ran
task.setdefault("comments", []).append({
"id": str(uuid.uuid4())[:8],
"message": f"🤖 **{agent}**\n\n{response}",
"timestamp": get_timestamp(),
})
if failed:
task["status"] = "blocked"
task["block_reason"] = response[:300]
append_audit({"action": "kanban_task_dispatch_failed", "task_id": task_id, "agent": agent})
else:
task["status"] = "done"
task["summary"] = response[:300]
task["completed_at"] = get_timestamp()
append_audit({"action": "kanban_task_dispatch_completed", "task_id": task_id, "agent": agent})
task["updated"] = get_timestamp()
save_kanban_task(task)
def load_goals():
if GOALS_FILE.exists():
@ -766,11 +1051,20 @@ def kanban_board(status: Optional[str] = None):
@app.get("/api/kanban/tasks/{task_id}")
def kanban_get_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
path = kanban_task_path(task_id)
if not path.exists():
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:
@ -788,16 +1082,20 @@ def kanban_create_task(data: KanbanTaskCreate):
}
save_kanban_task(task)
append_audit({"action": "kanban_task_created", "title": data.title})
if task["assignee"] in KANBAN_AGENTS:
dispatch_kanban_task(task["id"])
task = json.loads(kanban_task_path(task["id"]).read_text())
return task
except Exception as e:
raise HTTPException(500, str(e))
@app.patch("/api/kanban/tasks/{task_id}")
def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
path = KANBAN_DIR / f"{task_id}.json"
path = kanban_task_path(task_id)
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
assignee_changed = data.assignee is not None and data.assignee != task.get("assignee")
for field in ["title", "body", "status", "priority", "assignee"]:
val = getattr(data, field, None)
if val is not None:
@ -805,11 +1103,25 @@ def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
task["updated"] = get_timestamp()
save_kanban_task(task)
append_audit({"action": "kanban_task_updated", "task_id": task_id})
if assignee_changed and task["assignee"] in KANBAN_AGENTS:
dispatch_kanban_task(task_id)
task = json.loads(path.read_text())
return task
@app.post("/api/kanban/tasks/{task_id}/dispatch")
def kanban_dispatch_task(task_id: str):
path = kanban_task_path(task_id)
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
if task.get("assignee") not in KANBAN_AGENTS:
raise HTTPException(400, "Task must be assigned to opencode, hermes, or gemini to dispatch")
dispatch_kanban_task(task_id)
return {"status": "dispatched", "task_id": task_id}
@app.post("/api/kanban/tasks/{task_id}/complete")
def kanban_complete_task(task_id: str, data: KanbanComplete):
path = KANBAN_DIR / f"{task_id}.json"
path = kanban_task_path(task_id)
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
@ -823,7 +1135,7 @@ def kanban_complete_task(task_id: str, data: KanbanComplete):
@app.post("/api/kanban/tasks/{task_id}/block")
def kanban_block_task(task_id: str, data: KanbanBlock):
path = KANBAN_DIR / f"{task_id}.json"
path = kanban_task_path(task_id)
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
@ -836,7 +1148,7 @@ def kanban_block_task(task_id: str, data: KanbanBlock):
@app.post("/api/kanban/tasks/{task_id}/unblock")
def kanban_unblock_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
path = kanban_task_path(task_id)
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
@ -849,7 +1161,7 @@ def kanban_unblock_task(task_id: str):
@app.post("/api/kanban/tasks/{task_id}/comments")
def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
path = KANBAN_DIR / f"{task_id}.json"
path = kanban_task_path(task_id)
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
@ -866,7 +1178,7 @@ def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
@app.post("/api/kanban/links")
def kanban_add_link(data: KanbanLinkCreate):
for tid in [data.parent_id, data.child_id]:
path = KANBAN_DIR / f"{tid}.json"
path = kanban_task_path(tid)
if not path.exists():
raise HTTPException(404, f"Task {tid} not found")
t = json.loads(path.read_text())
@ -882,7 +1194,7 @@ def kanban_add_link(data: KanbanLinkCreate):
@app.delete("/api/kanban/links")
def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
for tid in [parent_id, child_id]:
path = KANBAN_DIR / f"{tid}.json"
path = kanban_task_path(tid)
if path.exists():
t = json.loads(path.read_text())
t.setdefault("links", [])
@ -893,12 +1205,17 @@ def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
@app.post("/api/kanban/dispatch")
def kanban_dispatch():
append_audit({"action": "kanban_dispatch_triggered"})
return {"status": "dispatch_triggered", "message": "Dispatcher notified"}
dispatched = []
for task in load_kanban_tasks():
if task.get("status") in ("todo", "ready") and task.get("assignee") in KANBAN_AGENTS:
dispatch_kanban_task(task["id"])
dispatched.append(task["id"])
append_audit({"action": "kanban_dispatch_triggered", "task_ids": dispatched})
return {"status": "dispatch_triggered", "dispatched": dispatched, "message": f"Dispatched {len(dispatched)} task(s)"}
@app.post("/api/kanban/tasks/{task_id}/specify")
def kanban_specify_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
path = kanban_task_path(task_id)
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
@ -910,7 +1227,7 @@ def kanban_specify_task(task_id: str):
@app.post("/api/kanban/tasks/{task_id}/decompose")
def kanban_decompose_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
path = kanban_task_path(task_id)
if not path.exists():
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
@ -1251,7 +1568,7 @@ if dashboard_dir.exists():
def index():
html_file = BASE_DIR / "dashboard" / "index.html"
if html_file.exists():
content = html_file.read_text()
content = html_file.read_text(encoding="utf-8")
content = content.replace('href="styles.css"', 'href="/dashboard/styles.css"')
content = content.replace('src="utils.js"', 'src="/dashboard/utils.js"')
content = content.replace('src="api.js"', 'src="/dashboard/api.js"')

View File

@ -36,11 +36,36 @@ $pythonArgs = @($python.Args)
& $pythonCommand @pythonArgs -m pip install -r requirements.txt --quiet
$port = & $pythonCommand @pythonArgs -c "import json; f=open('data/settings.json'); d=json.load(f); print(d.get('dashboard',{}).get('port',8080)); f.close()" 2>$null
if (-not $port) { $port = "8080" }
$configuredPort = & $pythonCommand @pythonArgs -c "import json; f=open('data/settings.json'); d=json.load(f); print(int(d.get('dashboard',{}).get('port',8080))); f.close()" 2>$null
if (-not $configuredPort) { $configuredPort = "8080" }
$configuredPort = [int]$configuredPort
function Find-FreePort {
param([int]$StartPort, [int]$MaxAttempts = 20)
for ($p = $StartPort; $p -lt $StartPort + $MaxAttempts; $p++) {
$listener = $null
try {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), $p)
$listener.Start()
return $p
} catch {
continue
} finally {
if ($listener) { $listener.Stop() }
}
}
throw "No free port found between $StartPort and $($StartPort + $MaxAttempts - 1). Close some other application or free a port and retry."
}
$port = Find-FreePort -StartPort $configuredPort
if ($port -ne $configuredPort) {
Write-Warning "Port $configuredPort is already in use (another app, or a leftover Agentic OS process) - using $port instead."
}
Write-Host "Dashboard: http://127.0.0.1:$port"
Write-Host "Press Ctrl+C to stop"
Write-Host ""
Start-Job -ScriptBlock { Start-Sleep -Seconds 2; Start-Process "http://127.0.0.1:$using:port" } | Out-Null
& $pythonCommand @pythonArgs server.py --port $port

View File

@ -24,11 +24,39 @@ fi
$PYTHON -m pip install -r requirements.txt --quiet 2>/dev/null
# Get port from settings or default
PORT=$($PYTHON -c "import json; f=open('data/settings.json'); d=json.load(f); print(d.get('dashboard',{}).get('port',8080)); f.close()" 2>/dev/null || echo "8080")
CONFIGURED_PORT=$($PYTHON -c "import json; f=open('data/settings.json'); d=json.load(f); print(d.get('dashboard',{}).get('port',8080)); f.close()" 2>/dev/null || echo "8080")
# Find a free port starting at CONFIGURED_PORT (in case it's already taken by another app)
PORT="$CONFIGURED_PORT"
FOUND=0
for _ in $(seq 1 20); do
if ! (exec 3<>"/dev/tcp/127.0.0.1/${PORT}") 2>/dev/null; then
FOUND=1
break
fi
exec 3>&- 2>/dev/null || true
PORT=$((PORT + 1))
done
if [ "$FOUND" -eq 0 ]; then
echo "ERROR: Could not find a free port after 20 attempts starting at ${CONFIGURED_PORT}."
exit 1
fi
if [ "$PORT" != "$CONFIGURED_PORT" ]; then
echo "WARNING: Port ${CONFIGURED_PORT} is already in use - using ${PORT} instead."
fi
echo "Dashboard: http://127.0.0.1:${PORT}"
echo "Press Ctrl+C to stop"
echo ""
# Best-effort auto-open browser once the server is up
( sleep 2
if command -v xdg-open &>/dev/null; then xdg-open "http://127.0.0.1:${PORT}" &>/dev/null
elif command -v open &>/dev/null; then open "http://127.0.0.1:${PORT}" &>/dev/null
fi
) &
# Start server
$PYTHON server.py --port "${PORT}"