From 072f1a8034913eba8652e86eafc3d491fa9580c0 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 04:43:35 +0200 Subject: [PATCH] feat(coding-agent/cli): implemented interactive process monitor and data helpers - Added shared process data collection and table formatting helpers to support rendering. - Added an interactive alt-screen TUI monitor for bare `omp ps` executions on TTYs. - Added a `--plain` flag to force static process listing output. --- packages/coding-agent/src/cli/ps-cli.ts | 346 ++---------------- packages/coding-agent/src/cli/ps-data.ts | 343 ++++++++++++++++++ packages/coding-agent/src/cli/ps-tui.ts | 425 +++++++++++++++++++++++ packages/coding-agent/src/commands/ps.ts | 2 + 4 files changed, 808 insertions(+), 308 deletions(-) create mode 100644 packages/coding-agent/src/cli/ps-data.ts create mode 100644 packages/coding-agent/src/cli/ps-tui.ts diff --git a/packages/coding-agent/src/cli/ps-cli.ts b/packages/coding-agent/src/cli/ps-cli.ts index 70c0c8535..2a944c7ef 100644 --- a/packages/coding-agent/src/cli/ps-cli.ts +++ b/packages/coding-agent/src/cli/ps-cli.ts @@ -2,40 +2,35 @@ * CLI handler for `omp ps` — inspect and control processes supervised by the * daemon broker from outside the harness. * - * Listing never spawns a broker: live scopes are queried over the broker - * socket, dead scopes are read from the persisted per-daemon `meta.json` - * snapshots. Actions (`stop`, `kill`, `restart`, `logs`, `info`) connect - * through the regular client, which revives a dead broker so it can re-adopt - * detached daemons before acting on them. + * A bare `omp ps` on a TTY opens the interactive alt-screen monitor + * (`ps-tui.ts`); `--plain`, `--json`, and non-TTY outputs use the static + * listing. Actions (`stop`, `kill`, `restart`, `logs`, `info`) connect through + * the regular client, which revives a dead broker so it can re-adopt detached + * daemons before acting on them. */ -import type { Dirent } from "node:fs"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { - formatDuration, - getDaemonRuntimeRoot, - getGlobalDaemonRuntimeDir, - getGlobalDaemonRuntimeRoot, - getProjectDir, - isEnoent, -} from "@oh-my-pi/pi-utils"; + +import { truncateToWidth } from "@oh-my-pi/pi-tui"; +import { formatDuration, getProjectDir } from "@oh-my-pi/pi-utils"; import chalk from "@oh-my-pi/pi-utils/chalk"; import { closeDaemonClients, - createDaemonBrokerClient, type DaemonBrokerClient, daemonClientForGlobal, daemonClientForProject, } from "../launch/client"; -import { canonicalProjectDir, daemonRuntimeDir, readDaemonScopeMeta } from "../launch/paths"; -import { readLiveDaemonBrokerPid } from "../launch/presence"; +import type { DaemonSnapshot } from "../launch/protocol"; import { - type DaemonSnapshot, - type DaemonSpec, - type DaemonState, - parseDaemonSnapshot, - parseDaemonSpec, -} from "../launch/protocol"; + collectReports, + daemonLabel, + formatCommand, + KILL_GRACE_MS, + type PsDaemonRow, + scopeHeader, + TABLE_HEADER, + TERMINAL_STATES, + tableCells, +} from "./ps-data"; +import { runPsTop } from "./ps-tui"; export type PsAction = "list" | "info" | "logs" | "stop" | "kill" | "restart"; @@ -47,6 +42,8 @@ export interface PsCommandArgs { /** list: include every project and global service scope on this machine. */ all: boolean; json: boolean; + /** list: force the static listing instead of the interactive monitor. */ + plain: boolean; /** Target another project directory instead of the current one. */ dir?: string; /** Target a machine-global service scope (e.g. browser-relay). */ @@ -64,41 +61,13 @@ export interface PsCommandArgs { }; } -/** One broker scope: a project runtime dir or a machine-global service dir. */ -interface PsScope { - kind: "project" | "global"; - runtimeDir: string; - /** Canonical project dir when known; used to connect and displayed as the scope label. */ - projectDir?: string; - /** Global service name (`kind === "global"`). */ - service?: string; - /** Live broker PID; undefined when no broker owns the scope. */ - brokerPid?: number; -} - -interface PsDaemonRow { - snapshot: DaemonSnapshot; - /** Launch command from the persisted spec, when readable. */ - command?: string; - cwd?: string; - /** False when the snapshot came from disk with no live broker supervising it. */ - supervised: boolean; -} - -interface PsScopeReport { - scope: PsScope; - daemons: PsDaemonRow[]; -} - -const PROJECT_SCOPE_KEY = /^[0-9a-f]{16}$/; -/** Hard SIGTERM->SIGKILL grace used by `kill`; effectively immediate. */ -const KILL_GRACE_MS = 100; -const TERMINAL_STATES: Partial> = { exited: true, failed: true }; - export async function runPsCommand(cmd: PsCommandArgs): Promise { try { if (cmd.action === "list") { - await runList(cmd); + const interactive = + !cmd.flags.json && !cmd.flags.plain && process.stdout.isTTY === true && process.stdin.isTTY === true; + if (interactive) await runPsTop(cmd.flags); + else await runList(cmd); return; } if (!cmd.name) { @@ -113,202 +82,11 @@ export async function runPsCommand(cmd: PsCommandArgs): Promise { } // --------------------------------------------------------------------------- -// Scope discovery -// --------------------------------------------------------------------------- - -/** The single scope explicitly targeted by `--global`/`--dir` (or the current project). */ -async function targetScope(flags: PsCommandArgs["flags"]): Promise { - if (flags.global) { - const runtimeDir = await canonicalRuntimeDir(getGlobalDaemonRuntimeDir(flags.global)); - return { - kind: "global", - runtimeDir, - service: flags.global, - brokerPid: await readLiveDaemonBrokerPid(runtimeDir), - }; - } - const projectDir = await canonicalProjectDir(flags.dir ?? getProjectDir()); - const runtimeDir = daemonRuntimeDir(projectDir); - return { kind: "project", runtimeDir, projectDir, brokerPid: await readLiveDaemonBrokerPid(runtimeDir) }; -} - -async function canonicalRuntimeDir(dir: string): Promise { - try { - return await fs.realpath(dir); - } catch { - return path.resolve(dir); - } -} - -/** Every scope on this machine: hash-keyed project scopes plus global service scopes. */ -async function discoverScopes(): Promise { - const scopes: PsScope[] = []; - for (const entry of await readdirQuiet(getDaemonRuntimeRoot())) { - if (!entry.isDirectory() || !PROJECT_SCOPE_KEY.test(entry.name)) continue; - const runtimeDir = path.join(getDaemonRuntimeRoot(), entry.name); - scopes.push({ - kind: "project", - runtimeDir, - projectDir: await resolveScopeProjectDir(runtimeDir), - brokerPid: await readLiveDaemonBrokerPid(runtimeDir), - }); - } - for (const entry of await readdirQuiet(getGlobalDaemonRuntimeRoot())) { - if (!entry.isDirectory()) continue; - const runtimeDir = await canonicalRuntimeDir(path.join(getGlobalDaemonRuntimeRoot(), entry.name)); - scopes.push({ - kind: "global", - runtimeDir, - service: entry.name, - brokerPid: await readLiveDaemonBrokerPid(runtimeDir), - }); - } - return scopes; -} - -async function readdirQuiet(dir: string): Promise { - try { - return await fs.readdir(dir, { withFileTypes: true }); - } catch (error) { - if (isEnoent(error)) return []; - throw error; - } -} - -/** - * Map a hash-keyed project runtime dir back to its project directory: - * broker-written `scope.json` first, then any registered client presence file - * (covers brokers started before scope metadata existed). - */ -async function resolveScopeProjectDir(runtimeDir: string): Promise { - const recorded = await readDaemonScopeMeta(runtimeDir); - if (recorded) return recorded; - for (const entry of await readdirQuiet(path.join(runtimeDir, "clients"))) { - try { - const decoded: unknown = await Bun.file(path.join(runtimeDir, "clients", entry.name)).json(); - if ( - typeof decoded === "object" && - decoded !== null && - "projectDir" in decoded && - typeof decoded.projectDir === "string" - ) { - return decoded.projectDir; - } - } catch { - // Unreadable presence files are skipped; the scope stays unlabeled. - } - } - return undefined; -} - -// --------------------------------------------------------------------------- -// Snapshot collection -// --------------------------------------------------------------------------- - -/** Persisted `{snapshot, spec}` pairs from `/daemons//meta.json`. */ -async function readPersistedDaemons( - runtimeDir: string, -): Promise> { - const persisted = new Map(); - const root = path.join(runtimeDir, "daemons"); - for (const entry of await readdirQuiet(root)) { - if (!entry.isDirectory()) continue; - try { - const decoded: unknown = await Bun.file(path.join(root, entry.name, "meta.json")).json(); - if (typeof decoded !== "object" || decoded === null || !("daemon" in decoded) || !("spec" in decoded)) - continue; - const snapshot = parseDaemonSnapshot(decoded.daemon); - persisted.set(snapshot.name, { snapshot, spec: parseDaemonSpec(decoded.spec) }); - } catch { - // Malformed or torn metadata is skipped; the broker rewrites it on next start. - } - } - return persisted; -} - -function processAlive(pid: number | undefined): boolean { - if (pid === undefined) return false; - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -/** - * Collect daemons for one scope. Live brokers are authoritative; dead scopes - * fall back to persisted snapshots, downgrading non-detached "running" records - * to exited (their broker took them down with it) and flagging detached - * survivors as unsupervised. - */ -async function collectScope(scope: PsScope): Promise { - const persisted = await readPersistedDaemons(scope.runtimeDir); - if (scope.brokerPid !== undefined) { - const connectDir = scope.projectDir ?? (process.platform === "win32" ? undefined : scope.runtimeDir); - if (connectDir !== undefined) { - try { - const client = await createDaemonBrokerClient(connectDir, { runtimeDir: scope.runtimeDir }); - try { - if (scope.projectDir === undefined) { - const ping = await client.request({ op: "ping" }); - if (ping.op === "ping") scope.projectDir = ping.projectDir; - } - const result = await client.request({ op: "list" }); - if (result.op !== "list") throw new Error(`Unexpected broker response ${result.op}`); - return { - scope, - daemons: result.daemons.map(snapshot => ({ - snapshot, - command: formatCommand(persisted.get(snapshot.name)?.spec), - cwd: persisted.get(snapshot.name)?.spec.cwd, - supervised: true, - })), - }; - } finally { - client.close(); - } - } catch { - // Broker died or refused mid-query; fall through to the offline view. - } - } - } - const daemons: PsDaemonRow[] = []; - for (const { snapshot, spec } of persisted.values()) { - const row: PsDaemonRow = { snapshot, command: formatCommand(spec), cwd: spec.cwd, supervised: false }; - if (!TERMINAL_STATES[snapshot.state]) { - const survivor = spec.detached && snapshot.state !== "stopping" && processAlive(snapshot.pid); - if (!survivor) { - // The broker died and took its non-detached children with it. - row.snapshot = { ...snapshot, state: "exited", exitReason: snapshot.exitReason ?? "broker exited" }; - } - } - daemons.push(row); - } - daemons.sort(compareRows); - return { scope, daemons }; -} - -function compareRows(a: PsDaemonRow, b: PsDaemonRow): number { - const aTerminal = TERMINAL_STATES[a.snapshot.state] === true; - const bTerminal = TERMINAL_STATES[b.snapshot.state] === true; - if (aTerminal !== bTerminal) return aTerminal ? 1 : -1; - return a.snapshot.name.localeCompare(b.snapshot.name); -} - -function formatCommand(spec: DaemonSpec | undefined): string | undefined { - return spec ? [spec.application, ...spec.args].join(" ") : undefined; -} - -// --------------------------------------------------------------------------- -// list +// Static list // --------------------------------------------------------------------------- async function runList(cmd: PsCommandArgs): Promise { - const scopes = cmd.flags.all ? await discoverScopes() : [await targetScope(cmd.flags)]; - const reports = (await Promise.all(scopes.map(collectScope))).filter( - report => !cmd.flags.all || report.daemons.length > 0 || report.scope.brokerPid !== undefined, - ); + const reports = await collectReports(cmd.flags.all, cmd.flags); if (cmd.flags.json) { console.log( JSON.stringify( @@ -351,61 +129,19 @@ async function runList(cmd: PsCommandArgs): Promise { } } -function scopeHeader(scope: PsScope): string { - const label = - scope.kind === "global" - ? `global ${chalk.bold(scope.service ?? path.basename(scope.runtimeDir))}` - : `project ${chalk.bold(scope.projectDir ?? path.basename(scope.runtimeDir))}`; - const broker = - scope.brokerPid !== undefined ? chalk.green(`broker pid ${scope.brokerPid}`) : chalk.dim("broker not running"); - return `${label} ${chalk.dim("—")} ${broker}`; -} - -function stateCell(row: PsDaemonRow): string { - const { snapshot } = row; - let text: string = snapshot.state; - if (TERMINAL_STATES[snapshot.state] && snapshot.exitCode !== undefined) text += `(${snapshot.exitCode})`; - const paint = - snapshot.state === "ready" || snapshot.state === "running" - ? chalk.green - : snapshot.state === "failed" - ? chalk.red - : TERMINAL_STATES[snapshot.state] - ? chalk.dim - : chalk.yellow; - return paint(text); -} - -function flagsCell(row: PsDaemonRow): string { - const parts: string[] = []; - if (row.snapshot.detached) parts.push("detached"); - else if (row.snapshot.persist) parts.push("persist"); - if (!row.supervised && !TERMINAL_STATES[row.snapshot.state]) parts.push("unsupervised"); - return parts.join(","); -} - -function uptimeCell(snapshot: DaemonSnapshot): string { - if (TERMINAL_STATES[snapshot.state]) return "-"; - return formatDuration(Date.now() - snapshot.startedAt); -} - function printTable(rows: PsDaemonRow[]): void { - const header = ["NAME", "STATE", "PID", "UPTIME", "RESTARTS", "FLAGS", "COMMAND"]; - const cells = rows.map(row => [ - row.snapshot.name, - stateCell(row), - row.snapshot.pid !== undefined && !TERMINAL_STATES[row.snapshot.state] ? String(row.snapshot.pid) : "-", - uptimeCell(row.snapshot), - String(row.snapshot.restartCount), - flagsCell(row), - row.command ?? "", - ]); - const widths = header.map((title, column) => + // Truncate to the terminal on a TTY; keep full lines when piped. + const maxWidth = process.stdout.isTTY ? (process.stdout.columns ?? 120) : Number.POSITIVE_INFINITY; + const cells = rows.map(tableCells); + const widths = TABLE_HEADER.map((title, column) => Math.max(title.length, ...cells.map(row => Bun.stringWidth(row[column]))), ); - const render = (row: string[]): string => - ` ${row.map((cell, column) => cell + " ".repeat(Math.max(0, widths[column] - Bun.stringWidth(cell)))).join(" ")}`.trimEnd(); - console.log(chalk.dim(render(header))); + const render = (row: string[]): string => { + const line = + ` ${row.map((cell, column) => cell + " ".repeat(Math.max(0, widths[column] - Bun.stringWidth(cell)))).join(" ")}`.trimEnd(); + return Number.isFinite(maxWidth) ? truncateToWidth(line, maxWidth) : line; + }; + console.log(chalk.dim(render([...TABLE_HEADER]))); for (const [index, row] of cells.entries()) { const line = render(row); console.log(TERMINAL_STATES[rows[index].snapshot.state] ? chalk.dim(line) : line); @@ -421,12 +157,6 @@ async function actionClient(flags: PsCommandArgs["flags"]): Promise { const client = await actionClient(cmd.flags); try { diff --git a/packages/coding-agent/src/cli/ps-data.ts b/packages/coding-agent/src/cli/ps-data.ts new file mode 100644 index 000000000..252c898b4 --- /dev/null +++ b/packages/coding-agent/src/cli/ps-data.ts @@ -0,0 +1,343 @@ +/** + * Data layer shared by the `omp ps` renderers (plain CLI and interactive TUI): + * broker-scope discovery, daemon snapshot collection, and display cells. + * + * Collection never spawns a broker: live scopes are queried over the broker + * socket, dead scopes are read from the persisted per-daemon `meta.json` + * snapshots. + */ +import type { Dirent } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { + formatDuration, + getDaemonRuntimeRoot, + getGlobalDaemonRuntimeDir, + getGlobalDaemonRuntimeRoot, + getProjectDir, + isEnoent, +} from "@oh-my-pi/pi-utils"; +import chalk from "@oh-my-pi/pi-utils/chalk"; +import { createDaemonBrokerClient, type DaemonBrokerClient } from "../launch/client"; +import { canonicalProjectDir, daemonRuntimeDir, readDaemonScopeMeta } from "../launch/paths"; +import { readLiveDaemonBrokerPid } from "../launch/presence"; +import { + type DaemonSnapshot, + type DaemonSpec, + type DaemonState, + parseDaemonSnapshot, + parseDaemonSpec, +} from "../launch/protocol"; + +/** One broker scope: a project runtime dir or a machine-global service dir. */ +export interface PsScope { + kind: "project" | "global"; + runtimeDir: string; + /** Canonical project dir when known; used to connect and displayed as the scope label. */ + projectDir?: string; + /** Global service name (`kind === "global"`). */ + service?: string; + /** Live broker PID; undefined when no broker owns the scope. */ + brokerPid?: number; +} + +export interface PsDaemonRow { + snapshot: DaemonSnapshot; + /** Launch command from the persisted spec, when readable. */ + command?: string; + cwd?: string; + /** False when the snapshot came from disk with no live broker supervising it. */ + supervised: boolean; +} + +export interface PsScopeReport { + scope: PsScope; + daemons: PsDaemonRow[]; +} + +/** Scope selector shared by every ps action: current project, `--dir`, or `--global`. */ +export interface PsTarget { + dir?: string; + global?: string; +} + +const PROJECT_SCOPE_KEY = /^[0-9a-f]{16}$/; +/** Hard SIGTERM->SIGKILL grace used by `kill`; effectively immediate. */ +export const KILL_GRACE_MS = 100; +export const TERMINAL_STATES: Partial> = { exited: true, failed: true }; + +// --------------------------------------------------------------------------- +// Scope discovery +// --------------------------------------------------------------------------- + +/** The single scope named by `target` (defaults to the current project). */ +export async function targetScope(target: PsTarget): Promise { + if (target.global) { + const runtimeDir = await canonicalRuntimeDir(getGlobalDaemonRuntimeDir(target.global)); + return { + kind: "global", + runtimeDir, + service: target.global, + brokerPid: await readLiveDaemonBrokerPid(runtimeDir), + }; + } + const projectDir = await canonicalProjectDir(target.dir ?? getProjectDir()); + const runtimeDir = daemonRuntimeDir(projectDir); + return { kind: "project", runtimeDir, projectDir, brokerPid: await readLiveDaemonBrokerPid(runtimeDir) }; +} + +async function canonicalRuntimeDir(dir: string): Promise { + try { + return await fs.realpath(dir); + } catch { + return path.resolve(dir); + } +} + +/** Every scope on this machine: hash-keyed project scopes plus global service scopes. */ +export async function discoverScopes(): Promise { + const scopes: PsScope[] = []; + for (const entry of await readdirQuiet(getDaemonRuntimeRoot())) { + if (!entry.isDirectory() || !PROJECT_SCOPE_KEY.test(entry.name)) continue; + const runtimeDir = path.join(getDaemonRuntimeRoot(), entry.name); + scopes.push({ + kind: "project", + runtimeDir, + projectDir: await resolveScopeProjectDir(runtimeDir), + brokerPid: await readLiveDaemonBrokerPid(runtimeDir), + }); + } + for (const entry of await readdirQuiet(getGlobalDaemonRuntimeRoot())) { + if (!entry.isDirectory()) continue; + const runtimeDir = await canonicalRuntimeDir(path.join(getGlobalDaemonRuntimeRoot(), entry.name)); + scopes.push({ + kind: "global", + runtimeDir, + service: entry.name, + brokerPid: await readLiveDaemonBrokerPid(runtimeDir), + }); + } + return scopes; +} + +async function readdirQuiet(dir: string): Promise { + try { + return await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + if (isEnoent(error)) return []; + throw error; + } +} + +/** + * Map a hash-keyed project runtime dir back to its project directory: + * broker-written `scope.json` first, then any registered client presence file + * (covers brokers started before scope metadata existed). + */ +async function resolveScopeProjectDir(runtimeDir: string): Promise { + const recorded = await readDaemonScopeMeta(runtimeDir); + if (recorded) return recorded; + for (const entry of await readdirQuiet(path.join(runtimeDir, "clients"))) { + try { + const decoded: unknown = await Bun.file(path.join(runtimeDir, "clients", entry.name)).json(); + if ( + typeof decoded === "object" && + decoded !== null && + "projectDir" in decoded && + typeof decoded.projectDir === "string" + ) { + return decoded.projectDir; + } + } catch { + // Unreadable presence files are skipped; the scope stays unlabeled. + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Snapshot collection +// --------------------------------------------------------------------------- + +/** + * Connect to a scope's broker. Undefined when the scope cannot be addressed + * (Windows pipe names derive from the project dir, which may be unknown for + * discovered scopes). The caller owns the returned client and must close it. + */ +export async function scopeClient(scope: PsScope): Promise { + const connectDir = scope.projectDir ?? (process.platform === "win32" ? undefined : scope.runtimeDir); + if (connectDir === undefined) return undefined; + return createDaemonBrokerClient(connectDir, { runtimeDir: scope.runtimeDir }); +} + +/** Persisted `{snapshot, spec}` pairs from `/daemons//meta.json`. */ +async function readPersistedDaemons( + runtimeDir: string, +): Promise> { + const persisted = new Map(); + const root = path.join(runtimeDir, "daemons"); + for (const entry of await readdirQuiet(root)) { + if (!entry.isDirectory()) continue; + try { + const decoded: unknown = await Bun.file(path.join(root, entry.name, "meta.json")).json(); + if (typeof decoded !== "object" || decoded === null || !("daemon" in decoded) || !("spec" in decoded)) + continue; + const snapshot = parseDaemonSnapshot(decoded.daemon); + persisted.set(snapshot.name, { snapshot, spec: parseDaemonSpec(decoded.spec) }); + } catch { + // Malformed or torn metadata is skipped; the broker rewrites it on next start. + } + } + return persisted; +} + +function processAlive(pid: number | undefined): boolean { + if (pid === undefined) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Collect daemons for one scope. Live brokers are authoritative; dead scopes + * fall back to persisted snapshots, downgrading non-detached "running" records + * to exited (their broker took them down with it) and flagging detached + * survivors as unsupervised. + */ +export async function collectScope(scope: PsScope): Promise { + const persisted = await readPersistedDaemons(scope.runtimeDir); + if (scope.brokerPid !== undefined) { + try { + const client = await scopeClient(scope); + if (client) { + try { + if (scope.projectDir === undefined) { + const ping = await client.request({ op: "ping" }); + if (ping.op === "ping") scope.projectDir = ping.projectDir; + } + const result = await client.request({ op: "list" }); + if (result.op !== "list") throw new Error(`Unexpected broker response ${result.op}`); + return { + scope, + daemons: result.daemons.map(snapshot => ({ + snapshot, + command: formatCommand(persisted.get(snapshot.name)?.spec), + cwd: persisted.get(snapshot.name)?.spec.cwd, + supervised: true, + })), + }; + } finally { + client.close(); + } + } + } catch { + // Broker died or refused mid-query; fall through to the offline view. + } + } + const daemons: PsDaemonRow[] = []; + for (const { snapshot, spec } of persisted.values()) { + const row: PsDaemonRow = { snapshot, command: formatCommand(spec), cwd: spec.cwd, supervised: false }; + if (!TERMINAL_STATES[snapshot.state]) { + const survivor = spec.detached && snapshot.state !== "stopping" && processAlive(snapshot.pid); + if (!survivor) { + // The broker died and took its non-detached children with it. + row.snapshot = { ...snapshot, state: "exited", exitReason: snapshot.exitReason ?? "broker exited" }; + } + } + daemons.push(row); + } + daemons.sort(compareRows); + return { scope, daemons }; +} + +function compareRows(a: PsDaemonRow, b: PsDaemonRow): number { + const aTerminal = TERMINAL_STATES[a.snapshot.state] === true; + const bTerminal = TERMINAL_STATES[b.snapshot.state] === true; + if (aTerminal !== bTerminal) return aTerminal ? 1 : -1; + return a.snapshot.name.localeCompare(b.snapshot.name); +} + +/** Collect the scopes selected by `all`/`target`, hiding empty dead scopes in the all view. */ +export async function collectReports(all: boolean, target: PsTarget): Promise { + const scopes = all ? await discoverScopes() : [await targetScope(target)]; + const reports = await Promise.all(scopes.map(collectScope)); + return reports.filter(report => !all || report.daemons.length > 0 || report.scope.brokerPid !== undefined); +} + +// --------------------------------------------------------------------------- +// Display cells (shared by the plain table and the interactive TUI) +// --------------------------------------------------------------------------- + +export function formatCommand(spec: DaemonSpec | undefined): string | undefined { + return spec ? [spec.application, ...spec.args].join(" ") : undefined; +} + +/** Collapse a launch command to one display line (inline scripts embed newlines/tabs). */ +export function collapseCommand(command: string | undefined): string { + return command ? command.replaceAll(/\s+/gu, " ").trim() : ""; +} + +/** One-line daemon summary used by action results and detail views. */ +export function daemonLabel(daemon: DaemonSnapshot): string { + const pid = daemon.pid === undefined ? "" : ` pid=${daemon.pid}`; + const exit = daemon.exitCode === undefined ? "" : ` exit=${daemon.exitCode}`; + return `${daemon.name}: ${daemon.state}${pid}${exit}`; +} + +/** Colored STATE cell, e.g. `ready`, `exited(143)`. */ +export function stateCell(row: PsDaemonRow): string { + const { snapshot } = row; + let text: string = snapshot.state; + if (TERMINAL_STATES[snapshot.state] && snapshot.exitCode !== undefined) text += `(${snapshot.exitCode})`; + const paint = + snapshot.state === "ready" || snapshot.state === "running" + ? chalk.green + : snapshot.state === "failed" + ? chalk.red + : TERMINAL_STATES[snapshot.state] + ? chalk.dim + : chalk.yellow; + return paint(text); +} + +export function flagsCell(row: PsDaemonRow): string { + const parts: string[] = []; + if (row.snapshot.detached) parts.push("detached"); + else if (row.snapshot.persist) parts.push("persist"); + if (!row.supervised && !TERMINAL_STATES[row.snapshot.state]) parts.push("unsupervised"); + return parts.join(","); +} + +export function uptimeCell(snapshot: DaemonSnapshot): string { + if (TERMINAL_STATES[snapshot.state]) return "-"; + return formatDuration(Date.now() - snapshot.startedAt); +} + +export const TABLE_HEADER = ["NAME", "STATE", "PID", "UPTIME", "RESTARTS", "FLAGS", "COMMAND"]; + +/** Raw (possibly colored) cells for one daemon row, aligned with {@link TABLE_HEADER}. */ +export function tableCells(row: PsDaemonRow): string[] { + return [ + row.snapshot.name, + stateCell(row), + row.snapshot.pid !== undefined && !TERMINAL_STATES[row.snapshot.state] ? String(row.snapshot.pid) : "-", + uptimeCell(row.snapshot), + String(row.snapshot.restartCount), + flagsCell(row), + collapseCommand(row.command), + ]; +} + +/** Scope heading, e.g. `project /work/pi — broker pid 1234`. */ +export function scopeHeader(scope: PsScope): string { + const label = + scope.kind === "global" + ? `global ${chalk.bold(scope.service ?? path.basename(scope.runtimeDir))}` + : `project ${chalk.bold(scope.projectDir ?? path.basename(scope.runtimeDir))}`; + const broker = + scope.brokerPid !== undefined ? chalk.green(`broker pid ${scope.brokerPid}`) : chalk.dim("broker not running"); + return `${label} ${chalk.dim("—")} ${broker}`; +} diff --git a/packages/coding-agent/src/cli/ps-tui.ts b/packages/coding-agent/src/cli/ps-tui.ts new file mode 100644 index 000000000..437e04cb6 --- /dev/null +++ b/packages/coding-agent/src/cli/ps-tui.ts @@ -0,0 +1,425 @@ +/** + * Interactive alt-screen monitor for `omp ps` (btop idiom): a live process + * table over every selected broker scope with in-place actions. + * + * Keys — table: `↑/↓`/`j/k` select, `enter`/`i` info, `l` logs, `s` stop, + * `x` kill, `r` restart, `a` toggle all scopes, `q`/`esc`/`ctrl+c` quit. + * Sub-views (info, logs): `esc`/`q` back. + */ +import { type Component, matchesKey, ProcessTerminal, TUI, truncateToWidth } from "@oh-my-pi/pi-tui"; +import { formatDuration } from "@oh-my-pi/pi-utils"; +import chalk from "@oh-my-pi/pi-utils/chalk"; +import { closeDaemonClients, type DaemonBrokerClient } from "../launch/client"; +import type { DaemonSnapshot, DaemonSpec } from "../launch/protocol"; +import { + collapseCommand, + collectReports, + daemonLabel, + formatCommand, + KILL_GRACE_MS, + type PsDaemonRow, + type PsScope, + type PsScopeReport, + type PsTarget, + scopeClient, + scopeHeader, + TABLE_HEADER, + TERMINAL_STATES, + tableCells, +} from "./ps-data"; + +const REFRESH_MS = 2_000; +const LOGS_POLL_MS = 1_000; +const STATUS_TTL_MS = 5_000; + +interface FlatRow { + scope: PsScope; + row: PsDaemonRow; +} + +type PsTopView = "table" | "info" | "logs"; + +/** Options accepted by the interactive monitor: scope selection from the list flags. */ +export interface PsTopOptions extends PsTarget { + all: boolean; +} + +class PsTopComponent implements Component { + readonly #ui: TUI; + readonly #target: PsTarget; + readonly #done = Promise.withResolvers(); + readonly #clients = new Map(); + #all: boolean; + #reports: PsScopeReport[] = []; + #flat: FlatRow[] = []; + #selected = 0; + /** `runtimeDir\u0000name` of the selection, kept stable across refreshes. */ + #selectedKey: string | undefined; + #scrollTop = 0; + #view: PsTopView = "table"; + #info: { daemon: DaemonSnapshot; spec: DaemonSpec } | undefined; + #logsLines: string[] = []; + #logsState = ""; + #logsTimer: NodeJS.Timeout | undefined; + #refreshTimer: NodeJS.Timeout | undefined; + #refreshing = false; + #lastRefresh = 0; + #status = ""; + #statusAt = 0; + #disposed = false; + + constructor(ui: TUI, options: PsTopOptions) { + this.#ui = ui; + this.#all = options.all; + this.#target = { dir: options.dir, global: options.global }; + } + + run(): Promise { + void this.#refresh(); + this.#refreshTimer = setInterval(() => void this.#refresh(), REFRESH_MS); + return this.#done.promise; + } + + dispose(): void { + this.#disposed = true; + clearInterval(this.#refreshTimer); + this.#stopLogsPoll(); + for (const client of this.#clients.values()) client.close(); + this.#clients.clear(); + } + + // -- data ---------------------------------------------------------------- + + async #refresh(): Promise { + if (this.#refreshing || this.#disposed) return; + this.#refreshing = true; + try { + const reports = await collectReports(this.#all, this.#target); + if (this.#disposed) return; + this.#reports = reports; + this.#flat = reports.flatMap(report => report.daemons.map(row => ({ scope: report.scope, row }))); + this.#lastRefresh = Date.now(); + this.#restoreSelection(); + this.#ui.requestRender(); + } catch (error) { + this.#setStatus(chalk.red(error instanceof Error ? error.message : String(error))); + } finally { + this.#refreshing = false; + } + } + + #restoreSelection(): void { + if (this.#selectedKey !== undefined) { + const index = this.#flat.findIndex(entry => flatKey(entry) === this.#selectedKey); + if (index >= 0) { + this.#selected = index; + return; + } + } + this.#selected = Math.max(0, Math.min(this.#selected, this.#flat.length - 1)); + this.#selectedKey = this.#flat[this.#selected] ? flatKey(this.#flat[this.#selected]) : undefined; + } + + #client(scope: PsScope): Promise { + const cached = this.#clients.get(scope.runtimeDir); + if (cached) return Promise.resolve(cached); + return scopeClient(scope).then(client => { + if (!client) return undefined; + if (this.#disposed) { + client.close(); + return undefined; + } + this.#clients.set(scope.runtimeDir, client); + return client; + }); + } + + #setStatus(text: string): void { + this.#status = text; + this.#statusAt = Date.now(); + this.#ui.requestRender(); + } + + // -- actions --------------------------------------------------------------- + + async #act(verb: "stop" | "kill" | "restart"): Promise { + const entry = this.#flat[this.#selected]; + if (!entry) return; + const name = entry.row.snapshot.name; + this.#setStatus(chalk.yellow(`${verb} ${name}…`)); + try { + const client = await this.#client(entry.scope); + if (!client) throw new Error("Scope is not addressable from this machine"); + const result = await client.request( + verb === "restart" + ? { op: "restart", name } + : { op: "stop", name, timeoutMs: verb === "kill" ? KILL_GRACE_MS : 5_000 }, + ); + if (result.op !== "restart" && result.op !== "stop") throw new Error(`Unexpected response ${result.op}`); + this.#setStatus( + chalk.green( + `${verb === "restart" ? "Restarted" : verb === "kill" ? "Killed" : "Stopped"} ${daemonLabel(result.daemon)}`, + ), + ); + void this.#refresh(); + } catch (error) { + this.#setStatus( + chalk.red(`${verb} ${name} failed: ${error instanceof Error ? error.message : String(error)}`), + ); + } + } + + async #openInfo(): Promise { + const entry = this.#flat[this.#selected]; + if (!entry) return; + try { + const client = await this.#client(entry.scope); + if (!client) throw new Error("Scope is not addressable from this machine"); + const result = await client.request({ op: "describe", name: entry.row.snapshot.name }); + if (result.op !== "describe") throw new Error(`Unexpected response ${result.op}`); + this.#info = { daemon: result.daemon, spec: result.spec }; + this.#view = "info"; + this.#ui.requestRender(); + } catch (error) { + this.#setStatus(chalk.red(error instanceof Error ? error.message : String(error))); + } + } + + #openLogs(): void { + const entry = this.#flat[this.#selected]; + if (!entry) return; + this.#view = "logs"; + this.#logsLines = []; + this.#logsState = ""; + const poll = async (): Promise => { + const current = this.#flat[this.#selected]; + if (this.#disposed || this.#view !== "logs" || !current) return; + try { + const client = await this.#client(current.scope); + if (!client) throw new Error("Scope is not addressable from this machine"); + const result = await client.request({ + op: "logs", + name: current.row.snapshot.name, + lines: Math.max(10, this.#ui.terminal.rows - 4), + head: false, + follow: false, + renderTerminalRows: true, + timeoutMs: 10_000, + }); + if (result.op !== "logs") throw new Error(`Unexpected response ${result.op}`); + this.#logsLines = result.terminalRows ?? result.text.replace(/\n$/, "").split("\n"); + this.#logsState = result.state; + this.#ui.requestRender(); + } catch (error) { + this.#logsLines = [chalk.red(error instanceof Error ? error.message : String(error))]; + this.#ui.requestRender(); + } + }; + void poll(); + this.#logsTimer = setInterval(() => void poll(), LOGS_POLL_MS); + } + + #stopLogsPoll(): void { + clearInterval(this.#logsTimer); + this.#logsTimer = undefined; + } + + #closeView(): void { + this.#stopLogsPoll(); + this.#view = "table"; + this.#info = undefined; + this.#ui.requestRender(); + } + + // -- input ----------------------------------------------------------------- + + handleInput(data: string): void { + if (matchesKey(data, "ctrl+c")) { + this.#done.resolve(); + return; + } + if (this.#view !== "table") { + if (matchesKey(data, "escape") || data === "q") this.#closeView(); + return; + } + if (matchesKey(data, "escape") || data === "q") { + this.#done.resolve(); + return; + } + if (matchesKey(data, "up") || data === "k") this.#moveSelection(-1); + else if (matchesKey(data, "down") || data === "j") this.#moveSelection(1); + else if (data === "a") { + this.#all = !this.#all; + this.#setStatus(chalk.dim(this.#all ? "Showing all scopes" : "Showing current scope")); + void this.#refresh(); + } else if (matchesKey(data, "enter") || data === "i") void this.#openInfo(); + else if (data === "l") this.#openLogs(); + else if (data === "s") void this.#act("stop"); + else if (data === "x") void this.#act("kill"); + else if (data === "r") void this.#act("restart"); + } + + #moveSelection(delta: number): void { + if (this.#flat.length === 0) return; + this.#selected = Math.max(0, Math.min(this.#flat.length - 1, this.#selected + delta)); + this.#selectedKey = flatKey(this.#flat[this.#selected]); + this.#ui.requestRender(); + } + + // -- render ------------------------------------------------------------ + + render(width: number): readonly string[] { + const height = Math.max(6, this.#ui.terminal.rows); + switch (this.#view) { + case "info": + return this.#renderInfo(width, height); + case "logs": + return this.#renderLogs(width, height); + default: + return this.#renderTable(width, height); + } + } + + #header(width: number, title: string): string { + const age = this.#lastRefresh ? `updated ${formatDuration(Date.now() - this.#lastRefresh)} ago` : "updating…"; + const left = ` ${chalk.bold("omp ps")} ${chalk.dim("·")} ${title}`; + const right = chalk.dim(age); + const pad = Math.max(1, width - Bun.stringWidth(left) - Bun.stringWidth(right) - 1); + return truncateToWidth(`${left}${" ".repeat(pad)}${right}`, width); + } + + #footer(width: number, hints: string): string[] { + const status = Date.now() - this.#statusAt < STATUS_TTL_MS ? this.#status : ""; + return [truncateToWidth(` ${status}`, width), truncateToWidth(chalk.dim(` ${hints}`), width)]; + } + + #renderTable(width: number, height: number): string[] { + const scopesLabel = `${this.#flat.length} process${this.#flat.length === 1 ? "" : "es"} in ${this.#reports.length} scope${this.#reports.length === 1 ? "" : "s"} ${chalk.dim(this.#all ? "(all)" : "(current)")}`; + const header = this.#header(width, scopesLabel); + const footer = this.#footer( + width, + "↑/↓ select · enter info · l logs · s stop · x kill · r restart · a all scopes · q quit", + ); + const bodyHeight = height - 1 - footer.length; + + const cells = this.#flat.map(entry => tableCells(entry.row)); + const widths = TABLE_HEADER.map((title, column) => + Math.max(title.length, ...cells.map(row => Bun.stringWidth(row[column]))), + ); + const renderRow = (row: string[]): string => + ` ${row.map((cell, column) => cell + " ".repeat(Math.max(0, widths[column] - Bun.stringWidth(cell)))).join(" ")}`.trimEnd(); + + // Body lines with the flat index carried for selection highlighting. + const body: { text: string; flat?: number }[] = []; + let flatIndex = 0; + for (const report of this.#reports) { + body.push({ text: ` ${scopeHeader(report.scope)}` }); + if (report.daemons.length === 0) { + body.push({ text: chalk.dim(" no processes") }); + } else { + body.push({ text: chalk.dim(renderRow([...TABLE_HEADER])) }); + for (const row of report.daemons) { + const line = renderRow(tableCells(row)); + body.push({ + text: TERMINAL_STATES[row.snapshot.state] ? chalk.dim(line) : line, + flat: flatIndex, + }); + flatIndex++; + } + } + body.push({ text: "" }); + } + if (body.length === 0) body.push({ text: chalk.dim(" No daemon broker scopes found.") }); + + // Keep the selected line inside the viewport. + const selectedLine = body.findIndex(line => line.flat === this.#selected); + if (selectedLine >= 0) { + if (selectedLine < this.#scrollTop) this.#scrollTop = selectedLine; + if (selectedLine >= this.#scrollTop + bodyHeight) this.#scrollTop = selectedLine - bodyHeight + 1; + } + this.#scrollTop = Math.max(0, Math.min(this.#scrollTop, Math.max(0, body.length - bodyHeight))); + + const lines = [header]; + for (const entry of body.slice(this.#scrollTop, this.#scrollTop + bodyHeight)) { + if (entry.flat === this.#selected) { + const plain = ` ❯${Bun.stripANSI(entry.text).slice(2)}`; + lines.push(truncateToWidth(chalk.inverse(plain.padEnd(width)), width)); + } else { + lines.push(truncateToWidth(entry.text, width)); + } + } + while (lines.length < height - footer.length) lines.push(""); + lines.push(...footer); + return lines; + } + + #renderInfo(width: number, height: number): string[] { + const info = this.#info; + const header = this.#header(width, "process info"); + const footer = this.#footer(width, "esc back · q back"); + const lines = [header, ""]; + if (info) { + const daemon = info.daemon; + lines.push(` ${chalk.bold(daemonLabel(daemon))}`); + lines.push(""); + lines.push(` command: ${collapseCommand(formatCommand(info.spec))}`); + lines.push(` cwd: ${info.spec.cwd}`); + if (!TERMINAL_STATES[daemon.state]) + lines.push(` uptime: ${formatDuration(Date.now() - daemon.startedAt)}`); + if (daemon.exitReason) lines.push(` exit: ${daemon.exitReason}`); + lines.push(` restarts: ${daemon.restartCount} (policy: ${info.spec.restart})`); + lines.push(` pty: ${info.spec.pty} persist: ${info.spec.persist} detached: ${info.spec.detached}`); + lines.push(` owner: ${daemon.owner ?? "-"}`); + } else { + lines.push(chalk.dim(" loading…")); + } + const truncated = lines.map(line => truncateToWidth(line, width)); + while (truncated.length < height - footer.length) truncated.push(""); + truncated.push(...footer); + return truncated; + } + + #renderLogs(width: number, height: number): string[] { + const entry = this.#flat[this.#selected]; + const name = entry?.row.snapshot.name ?? "?"; + const header = this.#header( + width, + `logs ${chalk.bold(name)}${this.#logsState ? chalk.dim(` · ${this.#logsState}`) : ""}`, + ); + const footer = this.#footer(width, "esc back · q back · view refreshes live"); + const bodyHeight = height - 1 - footer.length; + const tail = this.#logsLines.slice(-bodyHeight); + const lines = [header, ...tail.map(line => truncateToWidth(` ${line}`, width))]; + while (lines.length < height - footer.length) lines.push(""); + lines.push(...footer); + return lines; + } +} + +function flatKey(entry: FlatRow): string { + return `${entry.scope.runtimeDir}\u0000${entry.row.snapshot.name}`; +} + +/** Run the fullscreen interactive process monitor until the user quits. */ +export async function runPsTop(options: PsTopOptions): Promise { + const ui = new TUI(new ProcessTerminal()); + const component = new PsTopComponent(ui, options); + const overlay = ui.showOverlay(component, { + anchor: "top-left", + width: "100%", + maxHeight: "100%", + margin: 0, + fullscreen: true, + mouseTracking: false, + }); + ui.setFocus(component); + ui.start(); + try { + await component.run(); + } finally { + component.dispose(); + overlay.hide(); + ui.stop(); + await closeDaemonClients(); + } +} diff --git a/packages/coding-agent/src/commands/ps.ts b/packages/coding-agent/src/commands/ps.ts index bef2f78ad..8311d5e2d 100644 --- a/packages/coding-agent/src/commands/ps.ts +++ b/packages/coding-agent/src/commands/ps.ts @@ -26,6 +26,7 @@ export default class Ps extends Command { static flags = { all: Flags.boolean({ char: "a", description: "List every project and global service scope (list)" }), json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON" }), + plain: Flags.boolean({ description: "Static listing instead of the interactive monitor (list)" }), dir: Flags.string({ description: "Target another project directory instead of the current one" }), global: Flags.string({ description: "Target a machine-global service scope (e.g. browser-relay)" }), follow: Flags.boolean({ char: "f", description: "Keep streaming new output (logs)" }), @@ -52,6 +53,7 @@ export default class Ps extends Command { flags: { all: flags.all ?? false, json: flags.json ?? false, + plain: flags.plain ?? false, dir: flags.dir, global: flags.global, follow: flags.follow ?? false,