Merge pull request #9 from zumayaaustin-creator/claude/agentic-os-setup-5ubuao
Auto-detect free port and add one-click Windows launcher
This commit is contained in:
commit
fb3a1979dc
|
|
@ -0,0 +1,4 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
powershell -NoLogo -ExecutionPolicy Bypass -File "%~dp0start.ps1"
|
||||
pause
|
||||
|
|
@ -52,6 +52,9 @@ const api = {
|
|||
discoverStandards: () => api.post('/api/standards/discover'),
|
||||
chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller),
|
||||
getChatHistory: () => api.get('/api/chat/history'),
|
||||
// Terminal
|
||||
getTerminalSession: () => api.get('/api/terminal/session'),
|
||||
runTerminalCommand: (command) => api.post('/api/terminal/run', { command }),
|
||||
// Kanban
|
||||
getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
|
||||
getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),
|
||||
|
|
@ -64,6 +67,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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
async function renderTerminal() {
|
||||
const content = document.getElementById('pageContent');
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<h1 class="page-title">Terminal</h1>
|
||||
<p class="page-subtitle">Run shell commands directly on this machine</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn" onclick="clearTerminal()">🗑 Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-panel" onclick="focusTerminalInput()">
|
||||
<div id="terminalOutput" class="terminal-output"></div>
|
||||
<div class="terminal-input-row">
|
||||
<span class="terminal-prompt" id="terminalPrompt">$</span>
|
||||
<input id="terminalInput" class="terminal-input" type="text" autocomplete="off" spellcheck="false" onkeydown="handleTerminalKey(event)">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
window._terminalHistory = window._terminalHistory || [];
|
||||
window._terminalHistoryIndex = window._terminalHistory.length;
|
||||
window._terminalBusy = false;
|
||||
|
||||
try {
|
||||
const session = await api.getTerminalSession();
|
||||
updateTerminalPrompt(session.cwd);
|
||||
} catch {
|
||||
appendTerminalLine('Could not reach the terminal backend.', 'stderr');
|
||||
}
|
||||
|
||||
document.getElementById('terminalInput').focus();
|
||||
}
|
||||
|
||||
function focusTerminalInput() {
|
||||
const input = document.getElementById('terminalInput');
|
||||
if (input) input.focus();
|
||||
}
|
||||
|
||||
function updateTerminalPrompt(cwd) {
|
||||
const prompt = document.getElementById('terminalPrompt');
|
||||
if (prompt) prompt.textContent = `${cwd} $`;
|
||||
}
|
||||
|
||||
function appendTerminalLine(text, kind = '') {
|
||||
const output = document.getElementById('terminalOutput');
|
||||
if (!output || !text) return;
|
||||
const line = document.createElement('div');
|
||||
if (kind) line.className = `terminal-line-${kind}`;
|
||||
line.textContent = text;
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function clearTerminal() {
|
||||
const output = document.getElementById('terminalOutput');
|
||||
if (output) output.innerHTML = '';
|
||||
}
|
||||
|
||||
async function handleTerminalKey(e) {
|
||||
const input = e.target;
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (window._terminalHistoryIndex > 0) {
|
||||
window._terminalHistoryIndex--;
|
||||
input.value = window._terminalHistory[window._terminalHistoryIndex] || '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (window._terminalHistoryIndex < window._terminalHistory.length - 1) {
|
||||
window._terminalHistoryIndex++;
|
||||
input.value = window._terminalHistory[window._terminalHistoryIndex] || '';
|
||||
} else {
|
||||
window._terminalHistoryIndex = window._terminalHistory.length;
|
||||
input.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Enter' || window._terminalBusy) return;
|
||||
|
||||
const command = input.value.trim();
|
||||
input.value = '';
|
||||
if (!command) return;
|
||||
|
||||
window._terminalHistory.push(command);
|
||||
window._terminalHistoryIndex = window._terminalHistory.length;
|
||||
|
||||
appendTerminalLine(command, 'cmd');
|
||||
|
||||
if (command === 'clear' || command === 'cls') {
|
||||
clearTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
window._terminalBusy = true;
|
||||
input.disabled = true;
|
||||
try {
|
||||
const r = await api.runTerminalCommand(command);
|
||||
if (r.stdout) appendTerminalLine(r.stdout.replace(/\n$/, ''));
|
||||
if (r.stderr) appendTerminalLine(r.stderr.replace(/\n$/, ''), 'stderr');
|
||||
if (r.timed_out) appendTerminalLine('Command timed out after 60s.', 'info');
|
||||
updateTerminalPrompt(r.cwd);
|
||||
} catch (err) {
|
||||
appendTerminalLine(`Error: ${err.message}`, 'stderr');
|
||||
} finally {
|
||||
window._terminalBusy = false;
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
|
@ -821,6 +821,38 @@ pre {
|
|||
.chat-message { max-width: 95%; }
|
||||
}
|
||||
|
||||
/* Terminal */
|
||||
.terminal-panel {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #0a0e14;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.terminal-output {
|
||||
flex: 1; overflow-y: auto; padding: 16px;
|
||||
font-size: 13px; line-height: 1.6;
|
||||
color: #c9d1d9; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.terminal-line-cmd { color: #7ee787; }
|
||||
.terminal-line-cmd::before { content: '$ '; color: #58a6ff; }
|
||||
.terminal-line-stderr { color: #ff7b72; }
|
||||
.terminal-line-info { color: var(--text-muted); font-style: italic; }
|
||||
.terminal-input-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: #0d1117;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.terminal-prompt { color: #58a6ff; font-size: 13px; white-space: nowrap; }
|
||||
.terminal-input {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: #c9d1d9; font-family: var(--font-mono); font-size: 13px;
|
||||
}
|
||||
|
||||
/* ─── 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' },
|
||||
|
|
|
|||
22
install.ps1
22
install.ps1
|
|
@ -75,10 +75,28 @@ 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 .\start.ps1 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"
|
||||
|
|
|
|||
147
server.py
147
server.py
|
|
@ -6,9 +6,11 @@ Multi-agent orchestration server for opencode, Hermes, Gemini CLI
|
|||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -99,6 +101,9 @@ class ChatRequest(BaseModel):
|
|||
agent: str
|
||||
message: str
|
||||
|
||||
class TerminalRunRequest(BaseModel):
|
||||
command: str
|
||||
|
||||
# ─── Helper Functions ─────────────────────────────────────────────
|
||||
|
||||
def read_file(path: Path):
|
||||
|
|
@ -662,6 +667,40 @@ def chat(req: ChatRequest):
|
|||
def get_chat_history():
|
||||
return load_chat_history()
|
||||
|
||||
# ─── Routes: Terminal ─────────────────────────────────────────────
|
||||
|
||||
_terminal_cwd = str(BASE_DIR)
|
||||
|
||||
@app.get("/api/terminal/session")
|
||||
def get_terminal_session():
|
||||
return {"cwd": _terminal_cwd}
|
||||
|
||||
@app.post("/api/terminal/run")
|
||||
def run_terminal_command(req: TerminalRunRequest):
|
||||
global _terminal_cwd
|
||||
command = req.command.strip()
|
||||
if not command:
|
||||
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False}
|
||||
|
||||
if command == "cd" or command.startswith("cd "):
|
||||
target = command[2:].strip() or str(Path.home())
|
||||
new_dir = (Path(_terminal_cwd) / target).resolve() if not Path(target).is_absolute() else Path(target).resolve()
|
||||
if not new_dir.is_dir():
|
||||
return {"cwd": _terminal_cwd, "stdout": "", "stderr": f"cd: no such directory: {target}", "returncode": 1, "timed_out": False}
|
||||
_terminal_cwd = str(new_dir)
|
||||
append_audit({"action": "terminal_command", "command": "cd", "cwd": _terminal_cwd})
|
||||
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False}
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
command, shell=True, cwd=_terminal_cwd,
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
append_audit({"action": "terminal_command", "command": command[:200], "cwd": _terminal_cwd})
|
||||
return {"cwd": _terminal_cwd, "stdout": r.stdout, "stderr": r.stderr, "returncode": r.returncode, "timed_out": False}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "Command timed out after 60s.", "returncode": -1, "timed_out": True}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# v0.2.0 — New Feature Endpoints
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
|
@ -735,9 +774,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,7 +864,7 @@ 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())
|
||||
|
|
@ -788,16 +886,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 +907,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 +939,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 +952,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 +965,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 +982,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 +998,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 +1009,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 +1031,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())
|
||||
|
|
|
|||
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