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:
can1357
2026-08-20 04:39:24 +02:00
parent d7fbb503d8
commit e9acc575a7
11 changed files with 701 additions and 42 deletions
+1
View File
@@ -4,6 +4,7 @@
### Added ### 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 `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 `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. - 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), load: () => import("./commands/plugin").then(m => m.default),
help: commandHelp.pluginHelp, help: commandHelp.pluginHelp,
}, },
{
name: "ps",
load: () => import("./commands/ps").then(m => m.default),
help: commandHelp.psHelp,
},
{ {
name: "say", name: "say",
load: () => import("./commands/say").then(m => m.default), 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 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 = { export const readHelp = {
description: "Show what the read tool will return for a path, URL, or internal URI", description: "Show what the read tool will return for a path, URL, or internal URI",
} satisfies CommandMetadata; } satisfies CommandMetadata;
+549
View File
@@ -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;
}
+66
View File
@@ -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);
}
}
+3 -7
View File
@@ -7,7 +7,6 @@ import * as fs from "node:fs/promises";
import * as url from "node:url"; import * as url from "node:url";
import { getWorkProfile } from "@oh-my-pi/pi-natives"; import { getWorkProfile } from "@oh-my-pi/pi-natives";
import { import {
Container,
isNotificationSuppressed, isNotificationSuppressed,
Loader, Loader,
type OverlayHandle, type OverlayHandle,
@@ -20,6 +19,7 @@ import {
} from "@oh-my-pi/pi-tui"; } from "@oh-my-pi/pi-tui";
import { getSessionsDir } from "@oh-my-pi/pi-utils"; import { getSessionsDir } from "@oh-my-pi/pi-utils";
import { DynamicBorder } from "../modes/components/dynamic-border"; import { DynamicBorder } from "../modes/components/dynamic-border";
import { OverlayPanel } from "../modes/components/overlay-box";
import { TranscriptBlock } from "../modes/components/transcript-container"; import { TranscriptBlock } from "../modes/components/transcript-container";
import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme"; import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme";
import type { InteractiveModeContext } from "../modes/types"; import type { InteractiveModeContext } from "../modes/types";
@@ -72,18 +72,15 @@ const formatFileHyperlink = (path: string): string => {
/** /**
* Debug selector component. * Debug selector component.
*/ */
export class DebugSelectorComponent extends Container { export class DebugSelectorComponent extends OverlayPanel {
#selectList: SelectList; #selectList: SelectList;
constructor( constructor(
private ctx: InteractiveModeContext, private ctx: InteractiveModeContext,
onDone: () => void, 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)); this.addChild(new Spacer(1));
// Select list // Select list
@@ -99,7 +96,6 @@ export class DebugSelectorComponent extends Container {
}; };
this.addChild(this.#selectList); this.addChild(this.#selectList);
this.addChild(new DynamicBorder());
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
+8 -1
View File
@@ -7,7 +7,7 @@ import { isEexist, isEnoent, logger, postmortem, procmgr, sanitizeText, setProce
import { hostHasInheritableConsole } from "../eval/py/spawn-options"; import { hostHasInheritableConsole } from "../eval/py/spawn-options";
import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output"; import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output";
import { workerEnvFromParent } from "../subprocess/worker-client"; import { workerEnvFromParent } from "../subprocess/worker-client";
import { daemonBrokerEndpoint } from "./paths"; import { daemonBrokerEndpoint, writeDaemonScopeMeta } from "./paths";
import { hasLiveDaemonProjectPresence, pruneDeadDaemonRuntimeDirs } from "./presence"; import { hasLiveDaemonProjectPresence, pruneDeadDaemonRuntimeDirs } from "./presence";
import { import {
DAEMON_IDLE_GRACE_ENV, DAEMON_IDLE_GRACE_ENV,
@@ -1384,6 +1384,13 @@ export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStar
const lease = await acquireBrokerLease(runtimeDir); const lease = await acquireBrokerLease(runtimeDir);
if (!lease) return; if (!lease) return;
setProcessName("omp daemon broker"); 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). // Reclaim sibling daemon scopes left behind by dead brokers (issue #8674).
// Detached and non-throwing so it never delays clients connecting to us. // Detached and non-throwing so it never delays clients connecting to us.
void pruneDeadDaemonRuntimeDirs(runtimeDir).catch(error => { void pruneDeadDaemonRuntimeDirs(runtimeDir).catch(error => {
+2 -12
View File
@@ -2,10 +2,10 @@ import * as fs from "node:fs/promises";
import * as net from "node:net"; import * as net from "node:net";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; 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 { hostHasInheritableConsole } from "../eval/py/spawn-options";
import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client"; import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths"; import { canonicalProjectDir, daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
import { import {
DAEMON_BROKER_WORKER_ARG, DAEMON_BROKER_WORKER_ARG,
DAEMON_IDLE_GRACE_ENV, DAEMON_IDLE_GRACE_ENV,
@@ -64,16 +64,6 @@ export interface DaemonBrokerClient {
/** A request reached the broker and the broker rejected the operation. */ /** A request reached the broker and the broker rejected the operation. */
export class DaemonBrokerRejectedError extends Error {} 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> { async function readOrCreateToken(runtimeDir: string): Promise<string> {
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 }); await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
const tokenPath = path.join(runtimeDir, TOKEN_FILE); const tokenPath = path.join(runtimeDir, TOKEN_FILE);
+42 -1
View File
@@ -1,9 +1,50 @@
import * as fs from "node:fs/promises";
import * as path from "node:path"; 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. */ /** Resolve the private runtime directory shared by omp processes in one project directory. */
export { getDaemonRuntimeDir as daemonRuntimeDir }; 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. */ /** Resolve the Unix socket or Windows named pipe used by one daemon broker scope. */
export function daemonBrokerEndpoint(projectDir: string, runtimeDir: string): string { export function daemonBrokerEndpoint(projectDir: string, runtimeDir: string): string {
if (process.platform === "win32") { if (process.platform === "win32") {
+9 -19
View File
@@ -1,8 +1,8 @@
import type { Dirent } from "node:fs"; import type { Dirent } from "node:fs";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; import * as path from "node:path";
import { isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils"; import { isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
import { daemonRuntimeDir } from "./paths"; import { canonicalProjectDir, daemonRuntimeDir } from "./paths";
const CLIENTS_DIR = "clients"; const CLIENTS_DIR = "clients";
const BROKER_PID_FILE = "broker.pid"; const BROKER_PID_FILE = "broker.pid";
@@ -32,16 +32,6 @@ export interface DaemonProjectPresence {
close(): Promise<void>; 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. */ /** Register this omp process so project daemons survive while it remains alive. */
export async function registerDaemonProjectPresence( export async function registerDaemonProjectPresence(
projectDir: string, projectDir: string,
@@ -103,22 +93,22 @@ export async function hasLiveDaemonProjectPresence(runtimeDir: string): Promise<
return live; return live;
} }
/** Whether a runtime dir's recorded broker PID is still alive. */ /** PID recorded in the runtime dir's broker lease when that broker process is still alive; undefined otherwise. */
async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> { export async function readLiveDaemonBrokerPid(runtimeDir: string): Promise<number | undefined> {
let raw: unknown; let raw: unknown;
try { try {
raw = await Bun.file(path.join(runtimeDir, BROKER_PID_FILE)).json(); raw = await Bun.file(path.join(runtimeDir, BROKER_PID_FILE)).json();
} catch { } 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") { if (typeof raw !== "object" || raw === null || !("pid" in raw) || typeof raw.pid !== "number") {
return false; return undefined;
} }
try { try {
process.kill(raw.pid, 0); process.kill(raw.pid, 0);
return true; return raw.pid;
} catch { } catch {
return false; return undefined;
} }
} }
@@ -159,7 +149,7 @@ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Pro
try { try {
const stat = await fs.stat(dir); const stat = await fs.stat(dir);
if (now - stat.mtimeMs < DAEMON_RUNTIME_STALE_GRACE_MS) continue; 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; if (await hasLiveDaemonProjectPresence(dir)) continue;
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
} catch (error) { } catch (error) {
+12 -2
View File
@@ -854,10 +854,20 @@ export function getSecretPlaceholderKeyPath(): string {
return keyPath; return keyPath;
} }
/** Root directory containing every per-project daemon runtime scope (~/.omp/run/daemons; XDG default: $XDG_STATE_HOME/omp/run/daemons). */
export function getDaemonRuntimeRoot(): string {
return dirs.rootSubdir(path.join("run", "daemons"), "state");
}
/** Get the daemon runtime directory for a project (~/.omp/run/daemons/<hash>; XDG default: $XDG_STATE_HOME/omp/run/daemons/<hash>). */ /** Get the daemon runtime directory for a project (~/.omp/run/daemons/<hash>; XDG default: $XDG_STATE_HOME/omp/run/daemons/<hash>). */
export function getDaemonRuntimeDir(projectDir: string): string { export function getDaemonRuntimeDir(projectDir: string): string {
const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0"); const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
return dirs.rootSubdir(path.join("run", "daemons", key), "state"); return path.join(getDaemonRuntimeRoot(), key);
}
/** Root directory containing every machine-global daemon service scope. */
export function getGlobalDaemonRuntimeRoot(): string {
return path.join(getBaseConfigRoot(), "run", "daemons", "global");
} }
/** Get a profile-independent runtime directory for a machine-global daemon service. */ /** Get a profile-independent runtime directory for a machine-global daemon service. */
@@ -865,7 +875,7 @@ export function getGlobalDaemonRuntimeDir(service: string): string {
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(service)) { if (!/^[a-z0-9][a-z0-9._-]*$/i.test(service)) {
throw new Error(`Invalid global daemon service name: ${JSON.stringify(service)}`); throw new Error(`Invalid global daemon service name: ${JSON.stringify(service)}`);
} }
return path.join(getBaseConfigRoot(), "run", "daemons", "global", service); return path.join(getGlobalDaemonRuntimeRoot(), service);
} }
/** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */ /** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */