From 169e944c3c2771bee194fe6f92b72db5bf624bc9 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 28 May 2026 21:39:25 -0700 Subject: [PATCH] feat(agent-queue): Node live dashboard + bytelyst-cli integration - dashboard.mjs: zero-dep Node TUI (running workers w/ engine, elapsed, cwd, last log line + recent done/failed); 'dash' subcommand execs it - bytelyst-cli.sh: 'agent-queue' / 'aq' passthrough handled before the GITHUB_TOKEN + jq/curl gates; usage + interactive-menu entry - README: document dash + bytelyst-cli usage --- agent-queue/README.md | 12 ++- agent-queue/agent-queue.sh | 9 +- agent-queue/dashboard.mjs | 192 +++++++++++++++++++++++++++++++++++++ bytelyst-cli.sh | 16 +++- 4 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 agent-queue/dashboard.mjs diff --git a/agent-queue/README.md b/agent-queue/README.md index cac0acc..f2f210f 100644 --- a/agent-queue/README.md +++ b/agent-queue/README.md @@ -83,10 +83,20 @@ already have a `---` block. | `add [--engine E] [--cwd P] [--yolo\|--no-yolo]` | queue a prompt into `inbox/` | | `run [--max N] [--engine E] [--once]` | process the inbox (foreground loop) | | `status` | kanban counts + running-worker table | -| `watch [interval]` | live `status`, redrawn every N seconds (default 2) | +| `watch [interval]` | live `status` (bash), redrawn every N seconds (default 2) | +| `dash [--interval N]` | richer **Node** live dashboard — running workers (engine, elapsed, last log line) + recent done/failed | | `stop` | kill running workers + the run loop | | `logs [-f]` | print / follow a job's log | +## Via `bytelyst-cli.sh` + +Wired into the repo's unified CLI (no GitHub token required for this subcommand): + +```bash +./bytelyst-cli.sh agent-queue run --max 2 # full passthrough +./bytelyst-cli.sh aq status # short alias +``` + ## Folder layout ``` diff --git a/agent-queue/agent-queue.sh b/agent-queue/agent-queue.sh index 16f2f22..7af9675 100755 --- a/agent-queue/agent-queue.sh +++ b/agent-queue/agent-queue.sh @@ -303,6 +303,11 @@ cmd_watch() { while true; do clear; cmd_status; sleep "$interval"; done } +cmd_dash() { + command -v node >/dev/null 2>&1 || die "node not found — use 'watch' for the bash status view" + AGENT_QUEUE_ROOT="$QUEUE_ROOT" exec node "$SCRIPT_DIR/dashboard.mjs" "$@" +} + cmd_stop() { ensure_dirs local killed=0 f pid @@ -342,7 +347,8 @@ ${C_BOLD}COMMANDS${C_RESET} run [--max N] [--engine E] [--once] process inbox/ (foreground loop; Ctrl-C to stop) status show kanban counts + running workers - watch [interval] live status (default 2s) + watch [interval] live status (default 2s, bash) + dash [--interval N] richer live Node dashboard (recent done/failed too) stop kill running workers + the run loop logs [-f] print (or follow) a job's log help this message @@ -370,6 +376,7 @@ main() { run) cmd_run "$@";; status) cmd_status "$@";; watch) cmd_watch "$@";; + dash|dashboard) cmd_dash "$@";; stop) cmd_stop "$@";; logs) cmd_logs "$@";; help|-h|--help) usage;; diff --git a/agent-queue/dashboard.mjs b/agent-queue/dashboard.mjs new file mode 100644 index 0000000..4a4a034 --- /dev/null +++ b/agent-queue/dashboard.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// agent-queue dashboard — a zero-dependency live TUI for the folder queue. +// +// Reads the same queue/ state written by agent-queue.sh and re-renders a board +// every interval: kanban counts, running workers (engine, elapsed, last log line), +// and the most recent done/failed jobs. +// +// Usage: node dashboard.mjs [--interval 2] [--root /path/to/queue] +// AGENT_QUEUE_ROOT=/path node dashboard.mjs +// Quit: q or Ctrl-C + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ── args / config ─────────────────────────────────────────────────── +const argv = process.argv.slice(2); +const getArg = (flag, def) => { + const i = argv.indexOf(flag); + return i !== -1 && argv[i + 1] ? argv[i + 1] : def; +}; +const ROOT = path.resolve(getArg('--root', process.env.AGENT_QUEUE_ROOT || path.join(__dirname, 'queue'))); +const INTERVAL = Math.max(1, parseInt(getArg('--interval', '2'), 10)) * 1000; + +const DIRS = { + inbox: path.join(ROOT, 'inbox'), + doing: path.join(ROOT, 'doing'), + done: path.join(ROOT, 'done'), + failed: path.join(ROOT, 'failed'), + logs: path.join(ROOT, 'logs'), + state: path.join(ROOT, '.state'), +}; + +// ── ansi ──────────────────────────────────────────────────────────── +const C = { + reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', + red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', + blue: '\x1b[34m', cyan: '\x1b[36m', gray: '\x1b[90m', +}; +const c = (col, s) => `${C[col]}${s}${C.reset}`; + +// ── helpers ───────────────────────────────────────────────────────── +const listMd = (dir) => { + try { return fs.readdirSync(dir).filter((f) => f.endsWith('.md')); } + catch { return []; } +}; +const count = (dir) => listMd(dir).length; + +const parseMeta = (file) => { + const out = {}; + try { + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const i = line.indexOf('='); + if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); + } + } catch { /* ignore */ } + return out; +}; + +const pidAlive = (pid) => { + if (!pid) return false; + try { process.kill(Number(pid), 0); return true; } catch { return false; } +}; + +const lastLogLine = (job) => { + try { + const txt = fs.readFileSync(path.join(DIRS.logs, `${job}.log`), 'utf8'); + const lines = txt.split('\n').map((l) => l.trim()).filter(Boolean); + return lines.length ? lines[lines.length - 1] : ''; + } catch { return ''; } +}; + +const fmtElapsed = (startSec) => { + if (!startSec) return ' -- '; + const s = Math.max(0, Math.floor(Date.now() / 1000) - Number(startSec)); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + if (h > 0) return `${h}h${String(m % 60).padStart(2, '0')}m`; + return `${m}m${String(s % 60).padStart(2, '0')}s`; +}; + +const trunc = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s); +const shortPath = (p) => (p || '').replace(process.env.HOME || '~', '~'); + +const readMetas = () => { + let files = []; + try { files = fs.readdirSync(DIRS.state).filter((f) => f.endsWith('.meta')); } + catch { /* ignore */ } + return files.map((f) => parseMeta(path.join(DIRS.state, f))); +}; + +// ── render ────────────────────────────────────────────────────────── +const ENGINE_COLOR = { devin: 'cyan', claude: 'yellow', codex: 'green' }; + +function render() { + const metas = readMetas(); + const running = metas.filter((m) => !m.ended && pidAlive(m.pid)); + const finished = metas + .filter((m) => m.ended) + .sort((a, b) => Number(b.ended) - Number(a.ended)); + + const counts = { + inbox: count(DIRS.inbox), doing: count(DIRS.doing), + done: count(DIRS.done), failed: count(DIRS.failed), + }; + + const out = []; + out.push(''); + out.push(` ${C.bold}AGENT QUEUE${C.reset} ${c('gray', ROOT)}`); + out.push(` ${c('gray', new Date().toLocaleTimeString())} refresh ${INTERVAL / 1000}s ${c('gray', 'press q to quit')}`); + out.push(''); + out.push( + ` ${c('blue', '▢ inbox')} ${String(counts.inbox).padEnd(3)}` + + ` ${c('yellow', '◧ doing')} ${String(counts.doing).padEnd(3)}` + + ` ${c('green', '▣ done')} ${String(counts.done).padEnd(3)}` + + ` ${c('red', '✕ failed')} ${String(counts.failed).padEnd(3)}` + + ` ${C.bold}running ${running.length}${C.reset}` + ); + out.push(''); + + // running table + out.push(` ${C.bold}RUNNING${C.reset}`); + if (running.length === 0) { + out.push(` ${c('dim', 'no workers running')}`); + } else { + for (const m of running) { + const eng = m.engine || '?'; + const engC = ENGINE_COLOR[eng] || 'gray'; + const line = + ` ${c('bold', trunc(m.job || '?', 30).padEnd(30))} ` + + `${c(engC, eng.padEnd(7))} ` + + `${fmtElapsed(m.started).padStart(7)} ` + + `${c('gray', 'pid ' + (m.pid || '?'))}`; + out.push(line); + out.push(` ${c('dim', trunc(shortPath(m.cwd || ''), 70))}`); + const last = lastLogLine(m.job); + if (last) out.push(` ${c('cyan', '› ')}${c('dim', trunc(last, 70))}`); + } + } + out.push(''); + + // recent finished + out.push(` ${C.bold}RECENT${C.reset}`); + const recent = finished.slice(0, 6); + if (recent.length === 0) { + out.push(` ${c('dim', 'nothing finished yet')}`); + } else { + for (const m of recent) { + const ok = m.result === 'done'; + const mark = ok ? c('green', '▣') : c('red', '✕'); + const when = m.ended ? new Date(Number(m.ended) * 1000).toLocaleTimeString() : ''; + out.push( + ` ${mark} ${trunc(m.job || '?', 34).padEnd(34)} ` + + `${c('gray', (m.engine || '').padEnd(7))} ` + + `${ok ? c('green', 'done') : c('red', 'failed rc=' + (m.exit || '?'))} ${c('gray', when)}` + ); + } + } + out.push(''); + + // clear + paint + process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n'); +} + +// ── main loop + key handling ──────────────────────────────────────── +if (!fs.existsSync(ROOT)) { + process.stdout.write(`agent-queue: queue root not found: ${ROOT}\nRun \`agent-queue.sh init\` first.\n`); + process.exit(1); +} + +render(); +const timer = setInterval(render, INTERVAL); + +const quit = () => { + clearInterval(timer); + try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch { /* noop */ } + process.stdout.write(C.reset + '\n'); + process.exit(0); +}; + +if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (key) => { + if (key === 'q' || key === '\u0003') quit(); // q or Ctrl-C + }); +} +process.on('SIGINT', quit); +process.on('SIGTERM', quit); diff --git a/bytelyst-cli.sh b/bytelyst-cli.sh index e05cd66..99027d9 100644 --- a/bytelyst-cli.sh +++ b/bytelyst-cli.sh @@ -16,6 +16,15 @@ YELLOW=$(tput setaf 3) BLUE=$(tput setaf 4) RESET=$(tput sgr0) +CLI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# agent-queue delegates to the standalone tool (no GitHub token / jq / curl needed), +# so handle it BEFORE the GITHUB_TOKEN + required-tools gates below. +if [[ "${1:-}" == "agent-queue" || "${1:-}" == "aq" ]]; then + shift + exec "$CLI_DIR/agent-queue/agent-queue.sh" "$@" +fi + REQUIRED_TOOLS=(jq curl) # Check for required tools @@ -47,6 +56,7 @@ usage() { echo " check-collaborators --input " echo " export --type --output " echo " remove-user-from-all-repos --user [--input ]" + echo " agent-queue (aq) — agent prompt queue runner" echo " help Show this help message" echo "" echo "If no command is given, an interactive menu will be shown." @@ -209,14 +219,15 @@ remove_user_from_all_repos() { interactive_menu() { echo "${BLUE}Bytelyst CLI Interactive Menu${RESET}" - select opt in "List Public Repos" "List Private Repos" "Check Collaborators" "Export JSON" "Remove User from All Repos" "Exit"; do + select opt in "List Public Repos" "List Private Repos" "Check Collaborators" "Export JSON" "Remove User from All Repos" "Agent Queue Status" "Exit"; do case $REPLY in 1) read -p "Enter GitHub username: " user; list_public_repos --user "$user";; 2) read -p "Enter GitHub org: " org; list_private_repos --org "$org";; 3) read -p "Enter path to input.json: " input; check_collaborators --input "$input";; 4) read -p "Export type (repos/users): " type; read -p "Output file: " output; export_json --type "$type" --output "$output";; 5) read -p "Enter GitHub username: " user; remove_user_from_all_repos --user "$user";; - 6) exit 0;; + 6) "$CLI_DIR/agent-queue/agent-queue.sh" status;; + 7) exit 0;; *) echo "Invalid option.";; esac done @@ -234,6 +245,7 @@ case $1 in check-collaborators) shift; check_collaborators "$@";; export) shift; export_json "$@";; remove-user-from-all-repos) shift; remove_user_from_all_repos "$@";; + agent-queue|aq) shift; exec "$CLI_DIR/agent-queue/agent-queue.sh" "$@";; help|--help|-h) usage;; *) echo "${RED}Unknown command: $1${RESET}"; usage; exit 1;; esac \ No newline at end of file