From ff288ac711fd68b24852d5403c87c26bc0bd47b0 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 13 Jun 2026 13:53:38 -0500 Subject: [PATCH] feat(cli): one interactive `codegraph daemon` command, replaces stop/list (#863) Collapses the unreleased daemon controls into a single interactive command. `codegraph daemon` (alias `daemons`) opens an arrow-key picker (current project's daemon first, pre-selected), enter stops it, or pick "Stop all"; non-TTY prints a plain list. Removes stop/list/ps; reuses the unchanged daemon-registry machinery; the pick->stop loop is in daemon-manager.ts behind an injectable select (unit tested). Validated live on macOS/Linux (real clack picker driven via pty) and Windows (real runDaemonPicker + stopDaemonAt against a real daemon). Closes #845 follow-up. --- CHANGELOG.md | 2 +- __tests__/daemon-manager.test.ts | 113 +++++++++++++++++++++++++++++ src/bin/codegraph.ts | 93 ++++++++++-------------- src/mcp/daemon-manager.ts | 117 +++++++++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 58 deletions(-) create mode 100644 __tests__/daemon-manager.test.ts create mode 100644 src/mcp/daemon-manager.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ada79..fa6dd0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features -- New `codegraph list` and `codegraph stop` commands for managing the background daemon. `codegraph list` (alias `ps`) shows every running CodeGraph daemon — project, pid, version, uptime — with `--json` for scripting. `codegraph stop` stops the daemon for the current project (or `codegraph stop `, or `codegraph stop --all` to stop every daemon on the machine). Previously the only way to shut a daemon down was to hunt for its pid and `kill` it by hand. (#845) +- New `codegraph daemon` command (alias `daemons`) — an interactive manager for the background daemons. It shows what's running (your current project's daemon first, pre-selected), and you arrow-key to one and press enter to stop it, or pick "Stop all". Previously the only way to shut a daemon down was to hunt for its pid and `kill` it by hand. (#845) - The CodeGraph MCP server now self-heals if its main thread ever locks up. A lightweight watchdog notices when the process has stopped responding and stops it so a fresh one starts on your next request — it can no longer sit pinned at 100% CPU with no way to recover. Tune the detection window with `CODEGRAPH_WATCHDOG_TIMEOUT_MS`, or turn it off entirely with `CODEGRAPH_NO_WATCHDOG=1`. (#850) ### Fixes diff --git a/__tests__/daemon-manager.test.ts b/__tests__/daemon-manager.test.ts new file mode 100644 index 0000000..28fec95 --- /dev/null +++ b/__tests__/daemon-manager.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { + formatUptime, + buildPickItems, + runDaemonPicker, + STOP_ALL, + CANCEL, + type PickerDeps, +} from '../src/mcp/daemon-manager'; +import type { DaemonRecord, StopResult } from '../src/mcp/daemon-registry'; + +const rec = (root: string, pid: number, startedAt: number): DaemonRecord => ({ + root, pid, version: '1.0.0', socketPath: `${root}/.codegraph/daemon.sock`, startedAt, +}); + +describe('formatUptime', () => { + it('formats seconds / minutes / hours', () => { + expect(formatUptime(45_000)).toBe('45s'); + expect(formatUptime(12 * 60_000)).toBe('12m'); + expect(formatUptime((3 * 60 + 5) * 60_000)).toBe('3h 5m'); + }); +}); + +describe('buildPickItems', () => { + const old = rec('/p/old', 1, 1000); + const fresh = rec('/p/new', 2, 2000); + const cwd = rec('/p/cwd', 3, 500); + + it('orders newest-first and appends Stop all + Cancel', () => { + const items = buildPickItems([old, fresh], null, 3000); + expect(items.map((i) => i.value)).toEqual(['/p/new', '/p/old', STOP_ALL, CANCEL]); + expect(items[0].hint).toContain('pid 2'); + expect(items[0].hint).toContain('Running'); + }); + + it('omits Stop all for a single daemon (but keeps Cancel)', () => { + expect(buildPickItems([old], null, 3000).map((i) => i.value)).toEqual(['/p/old', CANCEL]); + }); + + it('floats the current project to the top, auto-selected and labelled', () => { + const items = buildPickItems([old, fresh, cwd], '/p/cwd', 3000); + expect(items[0].value).toBe('/p/cwd'); + expect(items[0].label).toContain('(current project)'); + expect(items.slice(1, 3).map((i) => i.value)).toEqual(['/p/new', '/p/old']); // rest newest-first + }); +}); + +describe('runDaemonPicker', () => { + // A fake registry whose list shrinks as daemons are stopped (like the real one). + function harness(initial: DaemonRecord[], choices: unknown[]) { + let daemons = [...initial]; + const stopped: string[] = []; + const notes: string[] = []; + let doneMsg = ''; + let i = 0; + const CANCEL_SYMBOL = Symbol('cancel'); + const deps: PickerDeps = { + list: () => daemons, + stop: async (root): Promise => { + daemons = daemons.filter((d) => d.root !== root); + stopped.push(root); + return { root, pid: 0, outcome: 'term' }; + }, + stopAll: async (): Promise => { + const all = daemons.map((d) => ({ root: d.root, pid: d.pid, outcome: 'term' as const })); + daemons = []; + stopped.push('ALL'); + return all; + }, + cwdRoot: null, + now: () => 5000, + select: async () => choices[i++], + isCancel: (v) => v === CANCEL_SYMBOL, + note: (m) => notes.push(m), + done: (m) => { doneMsg = m; }, + }; + return { deps, stopped, notes, getDone: () => doneMsg, CANCEL_SYMBOL }; + } + + it('stops the chosen daemon, then re-prompts and exits on Cancel', async () => { + const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], ['/p/b', CANCEL]); + await runDaemonPicker(h.deps); + expect(h.stopped).toEqual(['/p/b']); + expect(h.getDone()).toContain('Cancelled'); + }); + + it('keeps stopping until none remain', async () => { + const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], ['/p/a', '/p/b']); + await runDaemonPicker(h.deps); + expect(h.stopped).toEqual(['/p/a', '/p/b']); + expect(h.getDone()).toContain('All daemons stopped'); + }); + + it('Stop all stops everything in one shot', async () => { + const h = harness([rec('/p/a', 1, 1), rec('/p/b', 2, 2)], [STOP_ALL]); + await runDaemonPicker(h.deps); + expect(h.stopped).toEqual(['ALL']); + expect(h.getDone()).toBe('Done.'); + }); + + it('Cancel (and Esc/Ctrl-C) stop nothing', async () => { + const h1 = harness([rec('/p/a', 1, 1)], [CANCEL]); + await runDaemonPicker(h1.deps); + expect(h1.stopped).toEqual([]); + expect(h1.getDone()).toContain('Cancelled'); + + const h2 = harness([rec('/p/a', 1, 1)], [/* will use the cancel symbol */]); + h2.deps.select = async () => h2.CANCEL_SYMBOL; + await runDaemonPicker(h2.deps); + expect(h2.stopped).toEqual([]); + expect(h2.getDone()).toContain('Cancelled'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index d56b1f2..4836e0d 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1269,72 +1269,51 @@ function printFileTree( } /** - * codegraph stop — stop the background daemon for a project (or --all). + * codegraph daemon — interactive manager for the background daemons. Arrow keys + * to pick one (the current project's daemon floats to the top, auto-selected), + * enter to stop it. Falls back to a plain list when output isn't a TTY. */ program - .command('stop [path]') - .description('Stop the background CodeGraph daemon for a project (defaults to the current one)') - .option('-a, --all', 'Stop every running CodeGraph daemon on this machine') - .action(async (pathArg: string | undefined, options: { all?: boolean }) => { - const { stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry'); - try { - if (options.all) { - const results = await stopAllDaemons(); - const stopped = results.filter((r) => r.outcome === 'term' || r.outcome === 'kill'); - if (stopped.length === 0) { - info('No running CodeGraph daemons.'); - return; - } - for (const r of stopped) { - success(`Stopped daemon (pid ${r.pid}${r.outcome === 'kill' ? ', forced' : ''}) — ${r.root}`); - } - return; - } + .command('daemon') + .aliases(['daemons']) + .description('Manage running CodeGraph background daemons — pick one and press enter to stop it') + .action(async () => { + const { listDaemons, stopDaemonAt, stopAllDaemons } = await import('../mcp/daemon-registry'); + const { runDaemonPicker } = await import('../mcp/daemon-manager'); - const found = findNearestCodeGraphRoot(path.resolve(pathArg || process.cwd())); - if (!found) { - error('No CodeGraph project found here. Run inside a project, pass a path, or use --all.'); - process.exit(1); - } - let root = found; - try { root = fs.realpathSync(found); } catch { /* fall back to the un-realpath'd root */ } - - const result = await stopDaemonAt(root); - if (result.outcome === 'no-daemon' || result.outcome === 'not-running') { - info(`No daemon running for ${root}.`); - } else { - success(`Stopped daemon (pid ${result.pid}${result.outcome === 'kill' ? ', forced' : ''}) for ${root}.`); - } - } catch (err) { - error(`Failed to stop daemon: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); - -/** - * codegraph list — show running background daemons. - */ -program - .command('list') - .alias('ps') - .description('List running CodeGraph background daemons') - .option('--json', 'Output as JSON') - .action(async (options: { json?: boolean }) => { - const { listDaemons } = await import('../mcp/daemon-registry'); const daemons = listDaemons(); - - if (options.json) { - process.stdout.write(JSON.stringify(daemons, null, 2) + '\n'); - return; - } if (daemons.length === 0) { info('No CodeGraph daemons running.'); return; } - for (const d of daemons) { - console.log(`pid ${d.pid} v${d.version} up ${formatDuration(Date.now() - d.startedAt)} ${d.root}`); + + // No TTY (piped / CI / non-interactive) — can't do arrow-key selection, so + // just print what's running instead of crashing on a prompt with no input. + if (!process.stdout.isTTY || !process.stdin.isTTY) { + for (const d of daemons) { + console.log(`pid ${d.pid} v${d.version} up ${formatDuration(Date.now() - d.startedAt)} ${d.root}`); + } + return; } - info('Stop one with "codegraph stop ", or all with "codegraph stop --all".'); + + // The current project's daemon floats to the top and is pre-selected. + let cwdRoot: string | null = null; + const found = findNearestCodeGraphRoot(process.cwd()); + if (found) { try { cwdRoot = fs.realpathSync(found); } catch { cwdRoot = found; } } + + const clack = await importESM('@clack/prompts'); + clack.intro('CodeGraph daemons'); + await runDaemonPicker({ + list: listDaemons, + stop: stopDaemonAt, + stopAll: stopAllDaemons, + cwdRoot, + now: () => Date.now(), + select: (opts) => clack.select(opts), + isCancel: (v) => clack.isCancel(v), + note: (m) => clack.log.success(m), + done: (m) => clack.outro(m), + }); }); /** diff --git a/src/mcp/daemon-manager.ts b/src/mcp/daemon-manager.ts new file mode 100644 index 0000000..47a61e0 --- /dev/null +++ b/src/mcp/daemon-manager.ts @@ -0,0 +1,117 @@ +/** + * Interactive daemon manager — the logic behind `codegraph daemon` / `daemons`. + * + * Kept separate from the CLI (which owns the @clack/prompts wiring) so the + * selection/stop loop is unit-testable with a fake `select`: no TTY, no clack, + * no real daemons. The CLI passes the real clack `select`/`isCancel` plus the + * registry's list/stop functions. + */ +import * as path from 'path'; +import type { DaemonRecord, StopResult } from './daemon-registry'; + +/** Sentinel option values (not real roots, so they can't collide with a project path). */ +export const STOP_ALL = '__stop_all__'; +export const CANCEL = '__cancel__'; + +export interface PickItem { + value: string; + label: string; + hint?: string; +} + +/** Compact uptime: `45s`, `12m`, `3h 5m`. */ +export function formatUptime(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; +} + +/** + * Build the ordered, UI-ready option list: the current project's daemon first + * (so it's the auto-selected default), the rest newest-first, then "Stop all" + * (only when there's more than one) and "Cancel". + */ +export function buildPickItems(daemons: DaemonRecord[], cwdRoot: string | null, now: number): PickItem[] { + const cwd = cwdRoot != null ? path.resolve(cwdRoot) : null; + const ordered = [...daemons].sort((a, b) => { + if (cwd) { + const aCur = path.resolve(a.root) === cwd; + const bCur = path.resolve(b.root) === cwd; + if (aCur && !bCur) return -1; + if (bCur && !aCur) return 1; + } + return b.startedAt - a.startedAt; // newest first + }); + + const items: PickItem[] = ordered.map((d) => { + const current = cwd != null && path.resolve(d.root) === cwd; + return { + value: d.root, + label: current ? `${d.root} (current project)` : d.root, + hint: `pid ${d.pid} · up ${formatUptime(now - d.startedAt)} · Running`, + }; + }); + + if (items.length > 1) items.push({ value: STOP_ALL, label: 'Stop all', hint: '' }); + items.push({ value: CANCEL, label: 'Cancel', hint: '' }); + return items; +} + +export interface PickerDeps { + list: () => DaemonRecord[]; + stop: (root: string) => Promise; + stopAll: () => Promise; + /** Realpath'd root of the current project's daemon, or null. */ + cwdRoot: string | null; + now: () => number; + /** Render the picker; returns the chosen value or a cancel sentinel. */ + select: (opts: { message: string; options: PickItem[]; initialValue: string }) => Promise; + isCancel: (v: unknown) => boolean; + /** Per-action note (e.g. "Stopped daemon …"). */ + note: (msg: string) => void; + /** Final line + teardown (clack outro). */ + done: (msg: string) => void; +} + +/** + * Pick a daemon → stop it → re-prompt with what's left, until the user cancels + * (Esc / Ctrl-C / "Cancel"), picks "Stop all", or nothing remains. + */ +export async function runDaemonPicker(deps: PickerDeps): Promise { + for (;;) { + const daemons = deps.list(); + if (daemons.length === 0) { + deps.done('All daemons stopped.'); + return; + } + + const items = buildPickItems(daemons, deps.cwdRoot, deps.now()); + const choice = await deps.select({ + message: 'Select a daemon to stop', + options: items, + initialValue: items[0]?.value ?? CANCEL, // daemons.length > 0 here, so items[0] is a daemon + }); + + if (deps.isCancel(choice) || choice === CANCEL) { + deps.done('Cancelled.'); + return; + } + + if (choice === STOP_ALL) { + const results = await deps.stopAll(); + const n = results.filter((r) => r.outcome === 'term' || r.outcome === 'kill').length; + deps.note(`Stopped ${n} daemon${n === 1 ? '' : 's'}.`); + deps.done('Done.'); + return; + } + + const result = await deps.stop(String(choice)); + const forced = result.outcome === 'kill' ? ', forced' : ''; + deps.note(`Stopped daemon (pid ${result.pid}${forced}) — ${choice}`); + // Loop: the next iteration re-lists; if more remain it re-prompts, otherwise + // the top-of-loop empty check prints "All daemons stopped." + } +}