feat(coding-agent/cli): implemented ps command for daemon processes
- Added a new ps command to list, inspect, view logs, and control daemon processes. - Implemented CLI handlers and help metadata supporting daemon process operations. - Added helpers for managing daemon scope metadata, paths, and live broker PIDs.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added the `ps` command and CLI action for inspecting, monitoring, and controlling background daemon processes
|
||||
- Added `qwenTemplateReasoningEffort` to the `models.yml` `compat` schema, so the auto-enabled Qwen 3.8+ template effort dialect (`chat_template_kwargs.reasoning_effort`) can be switched off per provider/model for strict local servers that reject unknown `chat_template_kwargs`.
|
||||
- Added `tokenizer` to custom model and `modelOverrides` configuration. It overrides the catalog-resolved local tokenizer family for a model when a proxy serves a known model id with a different tokenizer.
|
||||
- Added `extendedContext` setting (`/settings` → Context → General, default on). When off, models with a premium long-context price tier (OpenAI GPT-5.6 Sol/Terra/Luna bill 2x input / 1.5x output above 272K input tokens, on both the API and subscription Codex) are capped at the standard-pricing threshold — they appear as 272K again and compaction fires before a request crosses into premium billing. Toggling mid-session re-clamps or restores the active model's window immediately. Anthropic Claude 4.6+ serves its full 1M window at standard pricing, so no Anthropic model is affected.
|
||||
|
||||
@@ -126,6 +126,11 @@ export const commands: CommandEntry[] = [
|
||||
load: () => import("./commands/plugin").then(m => m.default),
|
||||
help: commandHelp.pluginHelp,
|
||||
},
|
||||
{
|
||||
name: "ps",
|
||||
load: () => import("./commands/ps").then(m => m.default),
|
||||
help: commandHelp.psHelp,
|
||||
},
|
||||
{
|
||||
name: "say",
|
||||
load: () => import("./commands/say").then(m => m.default),
|
||||
|
||||
@@ -66,6 +66,10 @@ export const modelsHelp = { description: "List, search, and refresh available mo
|
||||
|
||||
export const pluginHelp = { description: "Manage plugins (install, uninstall, list, etc.)" } satisfies CommandMetadata;
|
||||
|
||||
export const psHelp = {
|
||||
description: "List and control daemon-supervised background processes (logs, stop, kill, restart)",
|
||||
} satisfies CommandMetadata;
|
||||
|
||||
export const readHelp = {
|
||||
description: "Show what the read tool will return for a path, URL, or internal URI",
|
||||
} satisfies CommandMetadata;
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 {
|
||||
closeDaemonClients,
|
||||
createDaemonBrokerClient,
|
||||
type DaemonBrokerClient,
|
||||
daemonClientForGlobal,
|
||||
daemonClientForProject,
|
||||
} 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";
|
||||
|
||||
export type PsAction = "list" | "info" | "logs" | "stop" | "kill" | "restart";
|
||||
|
||||
export interface PsCommandArgs {
|
||||
action: PsAction;
|
||||
/** Daemon name; required for every action except `list`. */
|
||||
name?: string;
|
||||
flags: {
|
||||
/** list: include every project and global service scope on this machine. */
|
||||
all: boolean;
|
||||
json: boolean;
|
||||
/** Target another project directory instead of the current one. */
|
||||
dir?: string;
|
||||
/** Target a machine-global service scope (e.g. browser-relay). */
|
||||
global?: string;
|
||||
/** logs: keep streaming new output. */
|
||||
follow: boolean;
|
||||
/** logs: read from the beginning instead of the tail. */
|
||||
head: boolean;
|
||||
/** logs: number of lines. */
|
||||
lines?: number;
|
||||
/** logs: regex filter. */
|
||||
grep?: string;
|
||||
/** stop: grace period in seconds before hard kill. */
|
||||
timeout?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 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);
|
||||
return;
|
||||
}
|
||||
if (!cmd.name) {
|
||||
console.error(chalk.red(`${cmd.action} requires a process name. Run \`omp ps\` to list processes.`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await runAction(cmd, cmd.name);
|
||||
} finally {
|
||||
await closeDaemonClients();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
);
|
||||
if (cmd.flags.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
reports.map(({ scope, daemons }) => ({
|
||||
kind: scope.kind,
|
||||
projectDir: scope.projectDir,
|
||||
service: scope.service,
|
||||
runtimeDir: scope.runtimeDir,
|
||||
brokerPid: scope.brokerPid,
|
||||
daemons: daemons.map(row => ({
|
||||
...row.snapshot,
|
||||
command: row.command,
|
||||
cwd: row.cwd,
|
||||
supervised: row.supervised,
|
||||
})),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (reports.length === 0) {
|
||||
console.log(chalk.dim("No daemon broker scopes found."));
|
||||
return;
|
||||
}
|
||||
let first = true;
|
||||
for (const report of reports) {
|
||||
if (!first) console.log("");
|
||||
first = false;
|
||||
console.log(scopeHeader(report.scope));
|
||||
if (report.daemons.length === 0) {
|
||||
console.log(chalk.dim(" no processes"));
|
||||
continue;
|
||||
}
|
||||
printTable(report.daemons);
|
||||
}
|
||||
if (!cmd.flags.all) {
|
||||
console.log(chalk.dim("\nUse --all to include other projects and global services."));
|
||||
}
|
||||
}
|
||||
|
||||
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) =>
|
||||
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)));
|
||||
for (const [index, row] of cells.entries()) {
|
||||
const line = render(row);
|
||||
console.log(TERMINAL_STATES[rows[index].snapshot.state] ? chalk.dim(line) : line);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Named actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function actionClient(flags: PsCommandArgs["flags"]): Promise<DaemonBrokerClient> {
|
||||
if (flags.global) return daemonClientForGlobal(flags.global);
|
||||
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 {
|
||||
switch (cmd.action) {
|
||||
case "info": {
|
||||
const result = await client.request({ op: "describe", name });
|
||||
if (result.op !== "describe") throw new Error(`Unexpected broker response ${result.op}`);
|
||||
if (cmd.flags.json) {
|
||||
console.log(JSON.stringify({ ...result.daemon, spec: result.spec }, null, 2));
|
||||
return;
|
||||
}
|
||||
const daemon = result.daemon;
|
||||
console.log(daemonLabel(daemon));
|
||||
console.log(` command: ${formatCommand(result.spec)}`);
|
||||
console.log(` cwd: ${result.spec.cwd}`);
|
||||
if (!TERMINAL_STATES[daemon.state])
|
||||
console.log(` uptime: ${formatDuration(Date.now() - daemon.startedAt)}`);
|
||||
if (daemon.exitReason) console.log(` exit: ${daemon.exitReason}`);
|
||||
console.log(` restarts: ${daemon.restartCount} (policy: ${result.spec.restart})`);
|
||||
console.log(
|
||||
` pty: ${result.spec.pty} persist: ${result.spec.persist} detached: ${result.spec.detached} owner: ${daemon.owner ?? "-"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "logs":
|
||||
await runLogs(cmd, client, name);
|
||||
return;
|
||||
case "stop":
|
||||
case "kill": {
|
||||
const timeoutMs = cmd.action === "kill" ? KILL_GRACE_MS : Math.round((cmd.flags.timeout ?? 5) * 1000);
|
||||
const result = await client.request({ op: "stop", name, timeoutMs });
|
||||
if (result.op !== "stop") throw new Error(`Unexpected broker response ${result.op}`);
|
||||
printDaemonResult(cmd, cmd.action === "kill" ? "Killed" : "Stopped", result.daemon);
|
||||
return;
|
||||
}
|
||||
case "restart": {
|
||||
const result = await client.request({ op: "restart", name });
|
||||
if (result.op !== "restart") throw new Error(`Unexpected broker response ${result.op}`);
|
||||
printDaemonResult(cmd, "Restarted", result.daemon);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unhandled action ${cmd.action}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function printDaemonResult(cmd: PsCommandArgs, verb: string, daemon: DaemonSnapshot): void {
|
||||
if (cmd.flags.json) console.log(JSON.stringify(daemon, null, 2));
|
||||
else console.log(`${verb} ${daemonLabel(daemon)}`);
|
||||
}
|
||||
|
||||
async function runLogs(cmd: PsCommandArgs, client: DaemonBrokerClient, name: string): Promise<void> {
|
||||
const lines = Math.max(1, Math.min(1_000, Math.floor(cmd.flags.lines ?? 100)));
|
||||
// Follow mode reads the full 1000-line window on every request so overlap
|
||||
// trimming sees a stable, sliding tail; the initial print is cut to `lines`.
|
||||
const first = await client.request({
|
||||
op: "logs",
|
||||
name,
|
||||
lines: cmd.flags.follow ? 1_000 : lines,
|
||||
head: cmd.flags.head,
|
||||
grep: cmd.flags.grep,
|
||||
follow: false,
|
||||
renderTerminalRows: !cmd.flags.follow,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (first.op !== "logs") throw new Error(`Unexpected broker response ${first.op}`);
|
||||
if (!cmd.flags.follow) {
|
||||
const text = first.terminalRows !== undefined ? first.terminalRows.join("\n") : first.text.replace(/\n$/, "");
|
||||
if (text) console.log(text);
|
||||
console.log(chalk.dim(`[${name}: ${first.state}]`));
|
||||
return;
|
||||
}
|
||||
const initial = first.text.replace(/\n$/, "").split("\n").slice(-lines).join("\n");
|
||||
if (initial) process.stdout.write(`${initial}\n`);
|
||||
let previous = first.text;
|
||||
let cursor = first.cursor;
|
||||
let state = first.state;
|
||||
while (!TERMINAL_STATES[state]) {
|
||||
const next = await client.request({
|
||||
op: "logs",
|
||||
name,
|
||||
lines: 1_000,
|
||||
head: false,
|
||||
grep: cmd.flags.grep,
|
||||
follow: true,
|
||||
cursor,
|
||||
renderTerminalRows: false,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (next.op !== "logs") throw new Error(`Unexpected broker response ${next.op}`);
|
||||
// The broker always returns the tail window (cursor is only a wait
|
||||
// watermark), so trim the part we already printed.
|
||||
const fresh = next.text.slice(overlapLength(previous, next.text));
|
||||
if (fresh) process.stdout.write(fresh.endsWith("\n") ? fresh : `${fresh}\n`);
|
||||
previous = next.text;
|
||||
cursor = next.cursor;
|
||||
state = next.state;
|
||||
}
|
||||
console.log(chalk.dim(`[${name}: ${state}]`));
|
||||
}
|
||||
|
||||
/** Longest suffix of `previous` that is a prefix of `next` — the already-printed portion of a tail window. */
|
||||
function overlapLength(previous: string, next: string): number {
|
||||
for (let k = Math.min(previous.length, next.length); k > 0; k--) {
|
||||
const offset = previous.length - k;
|
||||
let match = true;
|
||||
for (let i = 0; i < k; i++) {
|
||||
if (previous.charCodeAt(offset + i) !== next.charCodeAt(i)) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) return k;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Inspect and control daemon-broker supervised processes from outside the harness.
|
||||
*/
|
||||
|
||||
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
||||
import { psHelp as commandHelp } from "../cli/command-help";
|
||||
import { type PsAction, type PsCommandArgs, runPsCommand } from "../cli/ps-cli";
|
||||
|
||||
const ACTIONS: PsAction[] = ["list", "info", "logs", "stop", "kill", "restart"];
|
||||
|
||||
export default class Ps extends Command {
|
||||
static description = commandHelp.description;
|
||||
|
||||
static args = {
|
||||
action: Args.string({
|
||||
description: "list (default), info, logs, stop, kill, or restart",
|
||||
required: false,
|
||||
options: ACTIONS,
|
||||
}),
|
||||
name: Args.string({
|
||||
description: "Process name (required for every action except list)",
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
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" }),
|
||||
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)" }),
|
||||
head: Flags.boolean({ description: "Read from the beginning instead of the tail (logs)" }),
|
||||
lines: Flags.integer({ char: "n", description: "Number of log lines, max 1000 (logs)" }),
|
||||
grep: Flags.string({ description: "Regex filter applied to log lines (logs)" }),
|
||||
timeout: Flags.integer({ description: "Grace period in seconds before hard kill (stop)" }),
|
||||
};
|
||||
|
||||
static examples = [
|
||||
"omp ps",
|
||||
"omp ps --all",
|
||||
"omp ps logs web --follow",
|
||||
"omp ps stop web",
|
||||
"omp ps kill web",
|
||||
"omp ps info relay --global browser-relay",
|
||||
];
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(Ps);
|
||||
const cmd: PsCommandArgs = {
|
||||
action: (args.action ?? "list") as PsAction,
|
||||
name: args.name,
|
||||
flags: {
|
||||
all: flags.all ?? false,
|
||||
json: flags.json ?? false,
|
||||
dir: flags.dir,
|
||||
global: flags.global,
|
||||
follow: flags.follow ?? false,
|
||||
head: flags.head ?? false,
|
||||
lines: flags.lines,
|
||||
grep: flags.grep,
|
||||
timeout: flags.timeout,
|
||||
},
|
||||
};
|
||||
await runPsCommand(cmd);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import * as fs from "node:fs/promises";
|
||||
import * as url from "node:url";
|
||||
import { getWorkProfile } from "@oh-my-pi/pi-natives";
|
||||
import {
|
||||
Container,
|
||||
isNotificationSuppressed,
|
||||
Loader,
|
||||
type OverlayHandle,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { getSessionsDir } from "@oh-my-pi/pi-utils";
|
||||
import { DynamicBorder } from "../modes/components/dynamic-border";
|
||||
import { OverlayPanel } from "../modes/components/overlay-box";
|
||||
import { TranscriptBlock } from "../modes/components/transcript-container";
|
||||
import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "../modes/types";
|
||||
@@ -72,18 +72,15 @@ const formatFileHyperlink = (path: string): string => {
|
||||
/**
|
||||
* Debug selector component.
|
||||
*/
|
||||
export class DebugSelectorComponent extends Container {
|
||||
export class DebugSelectorComponent extends OverlayPanel {
|
||||
#selectList: SelectList;
|
||||
|
||||
constructor(
|
||||
private ctx: InteractiveModeContext,
|
||||
onDone: () => void,
|
||||
) {
|
||||
super();
|
||||
super("Debug Tools");
|
||||
|
||||
// Title
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Text(theme.bold(theme.fg("accent", "Debug Tools")), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Select list
|
||||
@@ -99,7 +96,6 @@ export class DebugSelectorComponent extends Container {
|
||||
};
|
||||
|
||||
this.addChild(this.#selectList);
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isEexist, isEnoent, logger, postmortem, procmgr, sanitizeText, setProce
|
||||
import { hostHasInheritableConsole } from "../eval/py/spawn-options";
|
||||
import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output";
|
||||
import { workerEnvFromParent } from "../subprocess/worker-client";
|
||||
import { daemonBrokerEndpoint } from "./paths";
|
||||
import { daemonBrokerEndpoint, writeDaemonScopeMeta } from "./paths";
|
||||
import { hasLiveDaemonProjectPresence, pruneDeadDaemonRuntimeDirs } from "./presence";
|
||||
import {
|
||||
DAEMON_IDLE_GRACE_ENV,
|
||||
@@ -1384,6 +1384,13 @@ export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStar
|
||||
const lease = await acquireBrokerLease(runtimeDir);
|
||||
if (!lease) return;
|
||||
setProcessName("omp daemon broker");
|
||||
// Record the scope's project dir so `omp ps` can map this hash-keyed runtime
|
||||
// dir back to its project (and derive the Windows pipe name) offline.
|
||||
void writeDaemonScopeMeta(runtimeDir, projectDir).catch(error => {
|
||||
logger.warn("Failed to record daemon scope metadata", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
// Reclaim sibling daemon scopes left behind by dead brokers (issue #8674).
|
||||
// Detached and non-throwing so it never delays clients connecting to us.
|
||||
void pruneDeadDaemonRuntimeDirs(runtimeDir).catch(error => {
|
||||
|
||||
@@ -2,10 +2,10 @@ import * as fs from "node:fs/promises";
|
||||
import * as net from "node:net";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { getGlobalDaemonRuntimeDir, isEexist, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { hostHasInheritableConsole } from "../eval/py/spawn-options";
|
||||
import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
|
||||
import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
|
||||
import { canonicalProjectDir, daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
|
||||
import {
|
||||
DAEMON_BROKER_WORKER_ARG,
|
||||
DAEMON_IDLE_GRACE_ENV,
|
||||
@@ -64,16 +64,6 @@ export interface DaemonBrokerClient {
|
||||
/** A request reached the broker and the broker rejected the operation. */
|
||||
export class DaemonBrokerRejectedError extends Error {}
|
||||
|
||||
async function canonicalProjectDir(projectDir: string): Promise<string> {
|
||||
const resolved = path.resolve(projectDir);
|
||||
try {
|
||||
return await fs.realpath(resolved);
|
||||
} catch (error) {
|
||||
if (isEnoent(error) || isEisdir(error)) return resolved;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readOrCreateToken(runtimeDir: string): Promise<string> {
|
||||
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
|
||||
const tokenPath = path.join(runtimeDir, TOKEN_FILE);
|
||||
|
||||
@@ -1,9 +1,50 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { getDaemonRuntimeDir } from "@oh-my-pi/pi-utils";
|
||||
import { getDaemonRuntimeDir, isEisdir, isEnoent } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/** Resolve the private runtime directory shared by omp processes in one project directory. */
|
||||
export { getDaemonRuntimeDir as daemonRuntimeDir };
|
||||
|
||||
/** File in a broker runtime dir recording which project (or global service dir) owns the scope. */
|
||||
const SCOPE_FILE = "scope.json";
|
||||
|
||||
/**
|
||||
* Canonicalize a project directory the same way every broker client does, so
|
||||
* hash-keyed runtime dirs and Windows pipe names agree across processes.
|
||||
* Missing paths resolve without realpath instead of failing.
|
||||
*/
|
||||
export async function canonicalProjectDir(projectDir: string): Promise<string> {
|
||||
const resolved = path.resolve(projectDir);
|
||||
try {
|
||||
return await fs.realpath(resolved);
|
||||
} catch (error) {
|
||||
if (isEnoent(error) || isEisdir(error)) return resolved;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the scope's canonical project directory inside its runtime dir.
|
||||
* Written by the broker at startup so out-of-process inspectors (`omp ps`)
|
||||
* can map a hash-keyed runtime dir back to its project.
|
||||
*/
|
||||
export async function writeDaemonScopeMeta(runtimeDir: string, projectDir: string): Promise<void> {
|
||||
await Bun.write(path.join(runtimeDir, SCOPE_FILE), JSON.stringify({ projectDir }));
|
||||
}
|
||||
|
||||
/** Read the project directory recorded for a runtime dir; undefined when absent or malformed. */
|
||||
export async function readDaemonScopeMeta(runtimeDir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const raw: unknown = await Bun.file(path.join(runtimeDir, SCOPE_FILE)).json();
|
||||
if (typeof raw === "object" && raw !== null && "projectDir" in raw && typeof raw.projectDir === "string") {
|
||||
return raw.projectDir;
|
||||
}
|
||||
} catch {
|
||||
// Missing or malformed scope metadata reads as unknown.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolve the Unix socket or Windows named pipe used by one daemon broker scope. */
|
||||
export function daemonBrokerEndpoint(projectDir: string, runtimeDir: string): string {
|
||||
if (process.platform === "win32") {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { daemonRuntimeDir } from "./paths";
|
||||
import { isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { canonicalProjectDir, daemonRuntimeDir } from "./paths";
|
||||
|
||||
const CLIENTS_DIR = "clients";
|
||||
const BROKER_PID_FILE = "broker.pid";
|
||||
@@ -32,16 +32,6 @@ export interface DaemonProjectPresence {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function canonicalProjectDir(projectDir: string): Promise<string> {
|
||||
const resolved = path.resolve(projectDir);
|
||||
try {
|
||||
return await fs.realpath(resolved);
|
||||
} catch (error) {
|
||||
if (isEnoent(error) || isEisdir(error)) return resolved;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Register this omp process so project daemons survive while it remains alive. */
|
||||
export async function registerDaemonProjectPresence(
|
||||
projectDir: string,
|
||||
@@ -103,22 +93,22 @@ export async function hasLiveDaemonProjectPresence(runtimeDir: string): Promise<
|
||||
return live;
|
||||
}
|
||||
|
||||
/** Whether a runtime dir's recorded broker PID is still alive. */
|
||||
async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> {
|
||||
/** PID recorded in the runtime dir's broker lease when that broker process is still alive; undefined otherwise. */
|
||||
export async function readLiveDaemonBrokerPid(runtimeDir: string): Promise<number | undefined> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await Bun.file(path.join(runtimeDir, BROKER_PID_FILE)).json();
|
||||
} catch {
|
||||
return false; // Missing or malformed broker.pid => no owning broker.
|
||||
return undefined; // Missing or malformed broker.pid => no owning broker.
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || !("pid" in raw) || typeof raw.pid !== "number") {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
process.kill(raw.pid, 0);
|
||||
return true;
|
||||
return raw.pid;
|
||||
} catch {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +149,7 @@ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Pro
|
||||
try {
|
||||
const stat = await fs.stat(dir);
|
||||
if (now - stat.mtimeMs < DAEMON_RUNTIME_STALE_GRACE_MS) continue;
|
||||
if (await hasLiveDaemonBroker(dir)) continue;
|
||||
if ((await readLiveDaemonBrokerPid(dir)) !== undefined) continue;
|
||||
if (await hasLiveDaemonProjectPresence(dir)) continue;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user