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
This commit is contained in:
parent
8f725f8587
commit
169e944c3c
@ -83,10 +83,20 @@ already have a `---` block.
|
|||||||
| `add <file> [--engine E] [--cwd P] [--yolo\|--no-yolo]` | queue a prompt into `inbox/` |
|
| `add <file> [--engine E] [--cwd P] [--yolo\|--no-yolo]` | queue a prompt into `inbox/` |
|
||||||
| `run [--max N] [--engine E] [--once]` | process the inbox (foreground loop) |
|
| `run [--max N] [--engine E] [--once]` | process the inbox (foreground loop) |
|
||||||
| `status` | kanban counts + running-worker table |
|
| `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 |
|
| `stop` | kill running workers + the run loop |
|
||||||
| `logs <job> [-f]` | print / follow a job's log |
|
| `logs <job> [-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
|
## Folder layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -303,6 +303,11 @@ cmd_watch() {
|
|||||||
while true; do clear; cmd_status; sleep "$interval"; done
|
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() {
|
cmd_stop() {
|
||||||
ensure_dirs
|
ensure_dirs
|
||||||
local killed=0 f pid
|
local killed=0 f pid
|
||||||
@ -342,7 +347,8 @@ ${C_BOLD}COMMANDS${C_RESET}
|
|||||||
run [--max N] [--engine E] [--once]
|
run [--max N] [--engine E] [--once]
|
||||||
process inbox/ (foreground loop; Ctrl-C to stop)
|
process inbox/ (foreground loop; Ctrl-C to stop)
|
||||||
status show kanban counts + running workers
|
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
|
stop kill running workers + the run loop
|
||||||
logs <job> [-f] print (or follow) a job's log
|
logs <job> [-f] print (or follow) a job's log
|
||||||
help this message
|
help this message
|
||||||
@ -370,6 +376,7 @@ main() {
|
|||||||
run) cmd_run "$@";;
|
run) cmd_run "$@";;
|
||||||
status) cmd_status "$@";;
|
status) cmd_status "$@";;
|
||||||
watch) cmd_watch "$@";;
|
watch) cmd_watch "$@";;
|
||||||
|
dash|dashboard) cmd_dash "$@";;
|
||||||
stop) cmd_stop "$@";;
|
stop) cmd_stop "$@";;
|
||||||
logs) cmd_logs "$@";;
|
logs) cmd_logs "$@";;
|
||||||
help|-h|--help) usage;;
|
help|-h|--help) usage;;
|
||||||
|
|||||||
192
agent-queue/dashboard.mjs
Normal file
192
agent-queue/dashboard.mjs
Normal file
@ -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);
|
||||||
@ -16,6 +16,15 @@ YELLOW=$(tput setaf 3)
|
|||||||
BLUE=$(tput setaf 4)
|
BLUE=$(tput setaf 4)
|
||||||
RESET=$(tput sgr0)
|
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)
|
REQUIRED_TOOLS=(jq curl)
|
||||||
|
|
||||||
# Check for required tools
|
# Check for required tools
|
||||||
@ -47,6 +56,7 @@ usage() {
|
|||||||
echo " check-collaborators --input <input.json>"
|
echo " check-collaborators --input <input.json>"
|
||||||
echo " export --type <repos|users> --output <file.json>"
|
echo " export --type <repos|users> --output <file.json>"
|
||||||
echo " remove-user-from-all-repos --user <username> [--input <file.json>]"
|
echo " remove-user-from-all-repos --user <username> [--input <file.json>]"
|
||||||
|
echo " agent-queue (aq) <init|add|run|status|watch|stop|logs> — agent prompt queue runner"
|
||||||
echo " help Show this help message"
|
echo " help Show this help message"
|
||||||
echo ""
|
echo ""
|
||||||
echo "If no command is given, an interactive menu will be shown."
|
echo "If no command is given, an interactive menu will be shown."
|
||||||
@ -209,14 +219,15 @@ remove_user_from_all_repos() {
|
|||||||
|
|
||||||
interactive_menu() {
|
interactive_menu() {
|
||||||
echo "${BLUE}Bytelyst CLI Interactive Menu${RESET}"
|
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
|
case $REPLY in
|
||||||
1) read -p "Enter GitHub username: " user; list_public_repos --user "$user";;
|
1) read -p "Enter GitHub username: " user; list_public_repos --user "$user";;
|
||||||
2) read -p "Enter GitHub org: " org; list_private_repos --org "$org";;
|
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";;
|
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";;
|
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";;
|
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.";;
|
*) echo "Invalid option.";;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@ -234,6 +245,7 @@ case $1 in
|
|||||||
check-collaborators) shift; check_collaborators "$@";;
|
check-collaborators) shift; check_collaborators "$@";;
|
||||||
export) shift; export_json "$@";;
|
export) shift; export_json "$@";;
|
||||||
remove-user-from-all-repos) shift; remove_user_from_all_repos "$@";;
|
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;;
|
help|--help|-h) usage;;
|
||||||
*) echo "${RED}Unknown command: $1${RESET}"; usage; exit 1;;
|
*) echo "${RED}Unknown command: $1${RESET}"; usage; exit 1;;
|
||||||
esac
|
esac
|
||||||
Loading…
Reference in New Issue
Block a user