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:
commit
2cb6e4b76f
|
|
@ -0,0 +1,4 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
powershell -NoLogo -ExecutionPolicy Bypass -File "%~dp0start.ps1"
|
||||
pause
|
||||
19
README.md
19
README.md
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
108
install.ps1
108
install.ps1
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
359
server.py
|
|
@ -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"')
|
||||
|
|
|
|||
29
start.ps1
29
start.ps1
|
|
@ -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
|
||||
|
|
|
|||
30
start.sh
30
start.sh
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Reference in New Issue