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.
This commit is contained in:
can1357
2026-08-20 04:43:35 +02:00
parent e9acc575a7
commit 072f1a8034
4 changed files with 808 additions and 308 deletions
+38 -308
View File
@@ -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<Record<DaemonState, true>> = { exited: true, failed: true };
export async function runPsCommand(cmd: PsCommandArgs): Promise<void> {
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<void> {
}
// ---------------------------------------------------------------------------
// Scope discovery
// ---------------------------------------------------------------------------
/** The single scope explicitly targeted by `--global`/`--dir` (or the current project). */
async function targetScope(flags: PsCommandArgs["flags"]): Promise<PsScope> {
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<string> {
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<PsScope[]> {
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<Dirent[]> {
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<string | undefined> {
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 `<runtimeDir>/daemons/<name>/meta.json`. */
async function readPersistedDaemons(
runtimeDir: string,
): Promise<Map<string, { snapshot: DaemonSnapshot; spec: DaemonSpec }>> {
const persisted = new Map<string, { snapshot: DaemonSnapshot; spec: DaemonSpec }>();
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<PsScopeReport> {
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<void> {
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<void> {
}
}
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<DaemonBroker
return daemonClientForProject(flags.dir ?? getProjectDir());
}
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}`;
}
async function runAction(cmd: PsCommandArgs, name: string): Promise<void> {
const client = await actionClient(cmd.flags);
try {
+343
View File
@@ -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<Record<DaemonState, true>> = { exited: true, failed: true };
// ---------------------------------------------------------------------------
// Scope discovery
// ---------------------------------------------------------------------------
/** The single scope named by `target` (defaults to the current project). */
export async function targetScope(target: PsTarget): Promise<PsScope> {
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<string> {
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<PsScope[]> {
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<Dirent[]> {
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<string | undefined> {
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<DaemonBrokerClient | undefined> {
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 `<runtimeDir>/daemons/<name>/meta.json`. */
async function readPersistedDaemons(
runtimeDir: string,
): Promise<Map<string, { snapshot: DaemonSnapshot; spec: DaemonSpec }>> {
const persisted = new Map<string, { snapshot: DaemonSnapshot; spec: DaemonSpec }>();
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<PsScopeReport> {
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<PsScopeReport[]> {
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}`;
}
+425
View File
@@ -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<void>();
readonly #clients = new Map<string, DaemonBrokerClient>();
#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> {
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<void> {
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<DaemonBrokerClient | undefined> {
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<void> {
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<void> {
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<void> => {
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<void> {
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();
}
}
+2
View File
@@ -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,