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:
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user