From e9acc575a7a42899f27abb225807ff8b9b03c2b8 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 04:39:24 +0200 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/cli-commands.ts | 5 + packages/coding-agent/src/cli/command-help.ts | 4 + packages/coding-agent/src/cli/ps-cli.ts | 549 ++++++++++++++++++ packages/coding-agent/src/commands/ps.ts | 66 +++ packages/coding-agent/src/debug/index.ts | 10 +- packages/coding-agent/src/launch/broker.ts | 9 +- packages/coding-agent/src/launch/client.ts | 14 +- packages/coding-agent/src/launch/paths.ts | 43 +- packages/coding-agent/src/launch/presence.ts | 28 +- packages/utils/src/dirs.ts | 14 +- 11 files changed, 701 insertions(+), 42 deletions(-) create mode 100644 packages/coding-agent/src/cli/ps-cli.ts create mode 100644 packages/coding-agent/src/commands/ps.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4764fef0f..093e05af8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index bb6946745..a598ef072 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -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), diff --git a/packages/coding-agent/src/cli/command-help.ts b/packages/coding-agent/src/cli/command-help.ts index 53aa38036..ff5d1e087 100644 --- a/packages/coding-agent/src/cli/command-help.ts +++ b/packages/coding-agent/src/cli/command-help.ts @@ -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; diff --git a/packages/coding-agent/src/cli/ps-cli.ts b/packages/coding-agent/src/cli/ps-cli.ts new file mode 100644 index 000000000..70c0c8535 --- /dev/null +++ b/packages/coding-agent/src/cli/ps-cli.ts @@ -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> = { exited: true, failed: true }; + +export async function runPsCommand(cmd: PsCommandArgs): Promise { + 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 { + if (flags.global) { + const runtimeDir = await canonicalRuntimeDir(getGlobalDaemonRuntimeDir(flags.global)); + return { + kind: "global", + runtimeDir, + service: flags.global, + brokerPid: await readLiveDaemonBrokerPid(runtimeDir), + }; + } + const projectDir = await canonicalProjectDir(flags.dir ?? getProjectDir()); + const runtimeDir = daemonRuntimeDir(projectDir); + return { kind: "project", runtimeDir, projectDir, brokerPid: await readLiveDaemonBrokerPid(runtimeDir) }; +} + +async function canonicalRuntimeDir(dir: string): Promise { + try { + return await fs.realpath(dir); + } catch { + return path.resolve(dir); + } +} + +/** Every scope on this machine: hash-keyed project scopes plus global service scopes. */ +async function discoverScopes(): Promise { + const scopes: PsScope[] = []; + for (const entry of await readdirQuiet(getDaemonRuntimeRoot())) { + if (!entry.isDirectory() || !PROJECT_SCOPE_KEY.test(entry.name)) continue; + const runtimeDir = path.join(getDaemonRuntimeRoot(), entry.name); + scopes.push({ + kind: "project", + runtimeDir, + projectDir: await resolveScopeProjectDir(runtimeDir), + brokerPid: await readLiveDaemonBrokerPid(runtimeDir), + }); + } + for (const entry of await readdirQuiet(getGlobalDaemonRuntimeRoot())) { + if (!entry.isDirectory()) continue; + const runtimeDir = await canonicalRuntimeDir(path.join(getGlobalDaemonRuntimeRoot(), entry.name)); + scopes.push({ + kind: "global", + runtimeDir, + service: entry.name, + brokerPid: await readLiveDaemonBrokerPid(runtimeDir), + }); + } + return scopes; +} + +async function readdirQuiet(dir: string): Promise { + try { + return await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + if (isEnoent(error)) return []; + throw error; + } +} + +/** + * Map a hash-keyed project runtime dir back to its project directory: + * broker-written `scope.json` first, then any registered client presence file + * (covers brokers started before scope metadata existed). + */ +async function resolveScopeProjectDir(runtimeDir: string): Promise { + const recorded = await readDaemonScopeMeta(runtimeDir); + if (recorded) return recorded; + for (const entry of await readdirQuiet(path.join(runtimeDir, "clients"))) { + try { + const decoded: unknown = await Bun.file(path.join(runtimeDir, "clients", entry.name)).json(); + if ( + typeof decoded === "object" && + decoded !== null && + "projectDir" in decoded && + typeof decoded.projectDir === "string" + ) { + return decoded.projectDir; + } + } catch { + // Unreadable presence files are skipped; the scope stays unlabeled. + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Snapshot collection +// --------------------------------------------------------------------------- + +/** Persisted `{snapshot, spec}` pairs from `/daemons//meta.json`. */ +async function readPersistedDaemons( + runtimeDir: string, +): Promise> { + const persisted = new Map(); + const root = path.join(runtimeDir, "daemons"); + for (const entry of await readdirQuiet(root)) { + if (!entry.isDirectory()) continue; + try { + const decoded: unknown = await Bun.file(path.join(root, entry.name, "meta.json")).json(); + if (typeof decoded !== "object" || decoded === null || !("daemon" in decoded) || !("spec" in decoded)) + continue; + const snapshot = parseDaemonSnapshot(decoded.daemon); + persisted.set(snapshot.name, { snapshot, spec: parseDaemonSpec(decoded.spec) }); + } catch { + // Malformed or torn metadata is skipped; the broker rewrites it on next start. + } + } + return persisted; +} + +function processAlive(pid: number | undefined): boolean { + if (pid === undefined) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Collect daemons for one scope. Live brokers are authoritative; dead scopes + * fall back to persisted snapshots, downgrading non-detached "running" records + * to exited (their broker took them down with it) and flagging detached + * survivors as unsupervised. + */ +async function collectScope(scope: PsScope): Promise { + const persisted = await readPersistedDaemons(scope.runtimeDir); + if (scope.brokerPid !== undefined) { + const connectDir = scope.projectDir ?? (process.platform === "win32" ? undefined : scope.runtimeDir); + if (connectDir !== undefined) { + try { + const client = await createDaemonBrokerClient(connectDir, { runtimeDir: scope.runtimeDir }); + try { + if (scope.projectDir === undefined) { + const ping = await client.request({ op: "ping" }); + if (ping.op === "ping") scope.projectDir = ping.projectDir; + } + const result = await client.request({ op: "list" }); + if (result.op !== "list") throw new Error(`Unexpected broker response ${result.op}`); + return { + scope, + daemons: result.daemons.map(snapshot => ({ + snapshot, + command: formatCommand(persisted.get(snapshot.name)?.spec), + cwd: persisted.get(snapshot.name)?.spec.cwd, + supervised: true, + })), + }; + } finally { + client.close(); + } + } catch { + // Broker died or refused mid-query; fall through to the offline view. + } + } + } + const daemons: PsDaemonRow[] = []; + for (const { snapshot, spec } of persisted.values()) { + const row: PsDaemonRow = { snapshot, command: formatCommand(spec), cwd: spec.cwd, supervised: false }; + if (!TERMINAL_STATES[snapshot.state]) { + const survivor = spec.detached && snapshot.state !== "stopping" && processAlive(snapshot.pid); + if (!survivor) { + // The broker died and took its non-detached children with it. + row.snapshot = { ...snapshot, state: "exited", exitReason: snapshot.exitReason ?? "broker exited" }; + } + } + daemons.push(row); + } + daemons.sort(compareRows); + return { scope, daemons }; +} + +function compareRows(a: PsDaemonRow, b: PsDaemonRow): number { + const aTerminal = TERMINAL_STATES[a.snapshot.state] === true; + const bTerminal = TERMINAL_STATES[b.snapshot.state] === true; + if (aTerminal !== bTerminal) return aTerminal ? 1 : -1; + return a.snapshot.name.localeCompare(b.snapshot.name); +} + +function formatCommand(spec: DaemonSpec | undefined): string | undefined { + return spec ? [spec.application, ...spec.args].join(" ") : undefined; +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +async function runList(cmd: PsCommandArgs): Promise { + const scopes = cmd.flags.all ? await discoverScopes() : [await targetScope(cmd.flags)]; + const reports = (await Promise.all(scopes.map(collectScope))).filter( + report => !cmd.flags.all || report.daemons.length > 0 || report.scope.brokerPid !== undefined, + ); + 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 { + 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 { + 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 { + 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; +} diff --git a/packages/coding-agent/src/commands/ps.ts b/packages/coding-agent/src/commands/ps.ts new file mode 100644 index 000000000..bef2f78ad --- /dev/null +++ b/packages/coding-agent/src/commands/ps.ts @@ -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 { + 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); + } +} diff --git a/packages/coding-agent/src/debug/index.ts b/packages/coding-agent/src/debug/index.ts index 16f602db2..7f88756ed 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -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 { diff --git a/packages/coding-agent/src/launch/broker.ts b/packages/coding-agent/src/launch/broker.ts index 285d16034..c108266cb 100644 --- a/packages/coding-agent/src/launch/broker.ts +++ b/packages/coding-agent/src/launch/broker.ts @@ -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 => { diff --git a/packages/coding-agent/src/launch/client.ts b/packages/coding-agent/src/launch/client.ts index 1c27a3720..e99d72eb7 100644 --- a/packages/coding-agent/src/launch/client.ts +++ b/packages/coding-agent/src/launch/client.ts @@ -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 { - 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 { await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 }); const tokenPath = path.join(runtimeDir, TOKEN_FILE); diff --git a/packages/coding-agent/src/launch/paths.ts b/packages/coding-agent/src/launch/paths.ts index ed735f90a..625908d5e 100644 --- a/packages/coding-agent/src/launch/paths.ts +++ b/packages/coding-agent/src/launch/paths.ts @@ -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 { + 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 { + 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 { + 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") { diff --git a/packages/coding-agent/src/launch/presence.ts b/packages/coding-agent/src/launch/presence.ts index 472734a48..6f8850805 100644 --- a/packages/coding-agent/src/launch/presence.ts +++ b/packages/coding-agent/src/launch/presence.ts @@ -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; } -async function canonicalProjectDir(projectDir: string): Promise { - 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 { +/** 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 { 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) { diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index c33b1471a..264b65641 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -854,10 +854,20 @@ export function getSecretPlaceholderKeyPath(): string { 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/; XDG default: $XDG_STATE_HOME/omp/run/daemons/). */ export function getDaemonRuntimeDir(projectDir: string): string { 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. */ @@ -865,7 +875,7 @@ export function getGlobalDaemonRuntimeDir(service: string): string { if (!/^[a-z0-9][a-z0-9._-]*$/i.test(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). */