302148523f
Publish terminal daemon completions to the session that started the process so idle agents can resume without polling hub status. Persist every unacknowledged generation with a stable completion ID and immutable snapshot. Replay the collection after reconnect or broker recovery, and clear each event only after the owning client acknowledges it. Signed-off-by: Christian Stewart <christian@aperture.us>
691 lines
24 KiB
TypeScript
691 lines
24 KiB
TypeScript
/**
|
|
* Hub launch half — supervision of project-scoped long-running processes
|
|
* (dev servers, watchers, debuggers, REPLs) through the shared daemon broker.
|
|
* Hub ops map 1:1 onto broker operations; the hub's `ps` op is the broker's
|
|
* `list`, and `send`/`wait` route here when they carry a process `name`.
|
|
*/
|
|
|
|
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
|
|
import { type DaemonBrokerClient, DaemonBrokerRejectedError, daemonClientForProject } from "../../launch/client";
|
|
import type { DaemonOperation, DaemonRpcResult, DaemonSnapshot, DaemonSpec, DaemonState } from "../../launch/protocol";
|
|
import { renderTerminalOutputIsolated } from "../../launch/terminal-output-worker-client";
|
|
import type { Theme, ThemeColor } from "../../modes/theme/theme";
|
|
import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../../tui";
|
|
import type { ToolSession } from "..";
|
|
import { resolveToCwd } from "../path-utils";
|
|
import {
|
|
capPreviewLines,
|
|
createCachedComponent,
|
|
DEFAULT_TERMINAL_PREVIEW_LINES,
|
|
formatDuration,
|
|
formatExpandHint,
|
|
formatMoreItems,
|
|
PREVIEW_LIMITS,
|
|
pluralize,
|
|
previewLine,
|
|
replaceTabs,
|
|
shortenPath,
|
|
TRUNCATE_LENGTHS,
|
|
truncateToWidth,
|
|
} from "../render-utils";
|
|
import { styleTerminalRow } from "../terminal-output";
|
|
import { ToolError } from "../tool-errors";
|
|
|
|
interface CompletionRegistration {
|
|
inFlight: number;
|
|
retained: boolean;
|
|
active: boolean;
|
|
cleanup: (preservePending?: boolean) => void;
|
|
}
|
|
|
|
interface CompletionLease {
|
|
retain: () => void;
|
|
reject: (preservePending?: boolean) => void;
|
|
hasConcurrentRequest: () => boolean;
|
|
}
|
|
|
|
const completionRegistrations = new WeakMap<
|
|
ToolSession,
|
|
Map<DaemonBrokerClient, Map<string, CompletionRegistration>>
|
|
>();
|
|
|
|
function registerCompletionSink(
|
|
session: ToolSession,
|
|
client: DaemonBrokerClient,
|
|
owner: string,
|
|
): CompletionLease | undefined {
|
|
if (!session.queueLaunchCompletion) return undefined;
|
|
let clients = completionRegistrations.get(session);
|
|
if (!clients) {
|
|
clients = new Map();
|
|
completionRegistrations.set(session, clients);
|
|
}
|
|
let owners = clients.get(client);
|
|
if (!owners) {
|
|
owners = new Map();
|
|
clients.set(client, owners);
|
|
}
|
|
let registration = owners.get(owner);
|
|
if (!registration) {
|
|
const unregister = client.onCompletion(owner, notification => {
|
|
if (session.isDisposed?.()) throw new Error("Session disposed before launch completion delivery");
|
|
const delivery = session.queueLaunchCompletion?.(notification);
|
|
if (!delivery) throw new Error("Session cannot accept launch completion delivery");
|
|
return delivery;
|
|
});
|
|
let unregisterDispose: (() => void) | void;
|
|
let unregisterSessionChange: (() => void) | void;
|
|
const cleanup = (preservePending = false): void => {
|
|
if (!registration?.active) return;
|
|
registration.active = false;
|
|
unregister({ preservePending });
|
|
unregisterDispose?.();
|
|
unregisterSessionChange?.();
|
|
owners.delete(owner);
|
|
if (owners.size === 0) clients.delete(client);
|
|
if (clients.size === 0) completionRegistrations.delete(session);
|
|
};
|
|
registration = { inFlight: 0, retained: false, active: true, cleanup };
|
|
owners.set(owner, registration);
|
|
unregisterDispose = session.registerDisposeCallback?.(() => cleanup(true));
|
|
unregisterSessionChange = session.registerSessionChangeCallback?.(() => cleanup(true));
|
|
}
|
|
registration.inFlight++;
|
|
let settled = false;
|
|
const settle = (retain: boolean, preservePending = false): void => {
|
|
if (settled || !registration.active) return;
|
|
settled = true;
|
|
registration.inFlight--;
|
|
if (retain) registration.retained = true;
|
|
if (!registration.retained && registration.inFlight === 0) registration.cleanup(preservePending);
|
|
};
|
|
return {
|
|
retain: () => settle(true),
|
|
hasConcurrentRequest: () => registration.active && registration.inFlight > 1,
|
|
reject: preservePending => settle(false, preservePending),
|
|
};
|
|
}
|
|
|
|
/** Broker-facing launch parameters; the hub adapts its `ps` op to `list` before calling in. */
|
|
export interface LaunchParams {
|
|
op: "start" | "list" | "logs" | "wait" | "send" | "stop" | "restart" | "describe";
|
|
name?: string;
|
|
application?: string;
|
|
args?: string[];
|
|
env?: Record<string, string>;
|
|
cwd?: string;
|
|
pty?: boolean;
|
|
ready?: { log?: string; port?: number; host?: string; timeout?: number };
|
|
restart?: "no" | "on-failure" | "always";
|
|
persist?: boolean;
|
|
detached?: boolean;
|
|
lines?: number;
|
|
head?: boolean;
|
|
grep?: string;
|
|
follow?: boolean;
|
|
cursor?: number;
|
|
for?: "ready" | "exit";
|
|
pattern?: string;
|
|
text?: string;
|
|
enter?: boolean;
|
|
keys?: string[];
|
|
signal?: "SIGINT" | "SIGTERM" | "SIGHUP" | "SIGQUIT" | "SIGKILL";
|
|
timeout?: number;
|
|
}
|
|
|
|
const KEY_INPUT: Record<string, string> = {
|
|
ENTER: "\r",
|
|
TAB: "\t",
|
|
ESCAPE: "\u001b",
|
|
CTRL_C: "\u0003",
|
|
CTRL_D: "\u0004",
|
|
UP: "\u001b[A",
|
|
DOWN: "\u001b[B",
|
|
RIGHT: "\u001b[C",
|
|
LEFT: "\u001b[D",
|
|
};
|
|
|
|
/** Terminal daemon lifecycle states — the process is no longer running. */
|
|
const TERMINAL_STATES: Partial<Record<DaemonState, true>> = { exited: true, failed: true };
|
|
|
|
/** Structured launch state retained for compact TUI rendering. */
|
|
export interface LaunchToolDetails {
|
|
op: LaunchParams["op"];
|
|
daemon?: DaemonSnapshot;
|
|
daemons?: DaemonSnapshot[];
|
|
cursor?: number;
|
|
timedOut?: boolean;
|
|
/** logs: daemon lifecycle state at read time. */
|
|
state?: DaemonState;
|
|
/** logs: virtual terminal rows for display; model-facing content remains sanitized text. */
|
|
terminalRows?: string[];
|
|
/** wait: output line that satisfied the pattern. */
|
|
matched?: string;
|
|
/** describe: immutable launch spec backing the command/cwd detail lines. */
|
|
spec?: DaemonSpec;
|
|
}
|
|
|
|
function requiredName(params: LaunchParams): string {
|
|
if (!params.name) throw new ToolError(`${params.op} requires name`);
|
|
return params.name;
|
|
}
|
|
|
|
function timeoutMs(value: number | undefined, fallbackSeconds: number): number {
|
|
const seconds = Math.max(0.05, Math.min(3_600, value ?? fallbackSeconds));
|
|
return Math.round(seconds * 1_000);
|
|
}
|
|
|
|
function commandSpec(params: LaunchParams, session: ToolSession): DaemonSpec {
|
|
const name = requiredName(params);
|
|
if (!params.application) throw new ToolError("start requires application");
|
|
const ready = params.ready;
|
|
const detached = params.detached ?? false;
|
|
if (ready?.port !== undefined && (!Number.isInteger(ready.port) || ready.port < 1 || ready.port > 65_535)) {
|
|
throw new ToolError("ready.port must be an integer from 1 to 65535");
|
|
}
|
|
if (ready && !ready.log && ready.port === undefined) throw new ToolError("ready requires log or port");
|
|
return {
|
|
name,
|
|
application: params.application,
|
|
args: params.args ?? [],
|
|
env: params.env ?? {},
|
|
cwd: resolveToCwd(params.cwd ?? session.cwd, session.cwd),
|
|
pty: detached ? false : (params.pty ?? true),
|
|
ready: ready
|
|
? {
|
|
log: ready.log,
|
|
port: ready.port,
|
|
host: ready.host,
|
|
timeoutMs: timeoutMs(ready.timeout, 30),
|
|
}
|
|
: undefined,
|
|
restart: params.restart ?? "no",
|
|
persist: (params.persist ?? false) || detached,
|
|
detached,
|
|
};
|
|
}
|
|
|
|
function sendData(params: LaunchParams): string | undefined {
|
|
let data = params.text ?? "";
|
|
if (params.text && (params.enter ?? true)) data += KEY_INPUT.ENTER;
|
|
for (const rawKey of params.keys ?? []) {
|
|
const key = rawKey.trim().toUpperCase();
|
|
const input = KEY_INPUT[key];
|
|
if (input === undefined) throw new ToolError(`Unsupported launch key ${rawKey}`);
|
|
data += input;
|
|
}
|
|
return data || undefined;
|
|
}
|
|
|
|
function operationFor(params: LaunchParams, session: ToolSession): DaemonOperation {
|
|
switch (params.op) {
|
|
case "start":
|
|
return { op: "start", spec: commandSpec(params, session), owner: session.getSessionId?.() ?? undefined };
|
|
case "list":
|
|
return { op: "list" };
|
|
case "logs":
|
|
return {
|
|
op: "logs",
|
|
name: requiredName(params),
|
|
lines: Math.min(1_000, Math.floor(params.lines ?? 100)),
|
|
head: params.head ?? false,
|
|
grep: params.grep,
|
|
follow: params.follow ?? false,
|
|
cursor: params.cursor,
|
|
renderTerminalRows: true,
|
|
timeoutMs: timeoutMs(params.timeout, 30),
|
|
};
|
|
case "wait":
|
|
return {
|
|
op: "wait",
|
|
name: requiredName(params),
|
|
for: params.for ?? "exit",
|
|
pattern: params.pattern,
|
|
timeoutMs: timeoutMs(params.timeout, 30),
|
|
};
|
|
case "send":
|
|
return {
|
|
op: "send",
|
|
name: requiredName(params),
|
|
data: sendData(params),
|
|
signal: params.signal,
|
|
};
|
|
case "stop":
|
|
return { op: "stop", name: requiredName(params), timeoutMs: timeoutMs(params.timeout, 5) };
|
|
case "restart":
|
|
return { op: "restart", name: requiredName(params) };
|
|
case "describe":
|
|
return { op: "describe", name: requiredName(params) };
|
|
}
|
|
}
|
|
|
|
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} uptime=${formatDuration(
|
|
(daemon.exitedAt ?? Date.now()) - daemon.startedAt,
|
|
)} restarts=${daemon.restartCount}${daemon.detached ? " detached" : daemon.persist ? " persistent" : ""}`;
|
|
}
|
|
|
|
/**
|
|
* Human sentences for the readiness conditions still unmet, e.g.
|
|
* `port 5173 on 127.0.0.1 never accepted connections`. `ready` (from the start
|
|
* params) adds the concrete pattern/port; absent it falls back to generic labels.
|
|
*/
|
|
function readyPendingSummary(daemon: DaemonSnapshot, ready?: LaunchParams["ready"]): string[] {
|
|
const parts: string[] = [];
|
|
for (const condition of daemon.readyPending ?? []) {
|
|
if (condition === "log") {
|
|
parts.push(ready?.log ? `log pattern /${ready.log}/ never matched` : "the log pattern never matched");
|
|
} else {
|
|
parts.push(
|
|
ready?.port !== undefined
|
|
? `port ${ready.port} on ${ready.host ?? "127.0.0.1"} never accepted connections`
|
|
: "the port never accepted connections",
|
|
);
|
|
}
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function toolContent(result: DaemonRpcResult, params: LaunchParams): string {
|
|
switch (result.op) {
|
|
case "ping":
|
|
case "shutdown":
|
|
throw new ToolError(`Internal daemon result ${result.op} is not tool-visible`);
|
|
case "start": {
|
|
const daemon = result.daemon;
|
|
const lines = [`${daemon.state === "failed" ? "Failed to launch" : "Started"} ${daemonLabel(daemon)}`];
|
|
if (daemon.state === "failed" && daemon.exitReason) lines.push(`Reason: ${daemon.exitReason}`);
|
|
if (daemon.readyMatch) lines.push(`Ready log matched: ${daemon.readyMatch}`);
|
|
if (result.readyTimedOut) {
|
|
const pending = readyPendingSummary(daemon, params.ready);
|
|
const cause = pending.length > 0 ? `: ${pending.join("; ")}` : "";
|
|
lines.push(
|
|
`NOT ready — readiness timed out after ${params.ready?.timeout ?? 30}s${cause}. The process is still running (state: ${daemon.state}); follow its logs or stop it.`,
|
|
);
|
|
} else if (params.ready && daemon.readyAt === undefined && TERMINAL_STATES[daemon.state]) {
|
|
lines.push("Process exited before readiness was observed.");
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
case "list":
|
|
return result.daemons.length
|
|
? result.daemons.map(daemon => `- ${daemonLabel(daemon)}`).join("\n")
|
|
: "No daemons.";
|
|
case "logs": {
|
|
const text = sanitizeText(result.text);
|
|
return `${text}${text && !text.endsWith("\n") ? "\n" : ""}[${result.name}: ${result.state}; cursor=${result.cursor}${result.timedOut ? "; follow timed out" : ""}]`;
|
|
}
|
|
case "wait": {
|
|
const lines = [daemonLabel(result.daemon)];
|
|
if (result.matched) lines.push(`Matched: ${result.matched}`);
|
|
if (result.timedOut) {
|
|
const pending = readyPendingSummary(result.daemon);
|
|
lines.push(`Wait timed out${pending.length > 0 ? ` (still waiting on: ${pending.join("; ")})` : ""}.`);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
case "send":
|
|
return `Sent input to ${daemonLabel(result.daemon)}`;
|
|
case "stop":
|
|
return `Stopped ${daemonLabel(result.daemon)}`;
|
|
case "restart":
|
|
return `Restarted ${daemonLabel(result.daemon)}`;
|
|
case "describe":
|
|
return [
|
|
daemonLabel(result.daemon),
|
|
`Command: ${[result.spec.application, ...result.spec.args].join(" ")}`,
|
|
`Cwd: ${shortenPath(result.spec.cwd)}`,
|
|
`PTY: ${result.spec.pty}; restart=${result.spec.restart}; persist=${result.spec.persist}; detached=${result.spec.detached}`,
|
|
].join("\n");
|
|
}
|
|
}
|
|
|
|
/** Resolve display rows while keeping legacy raw replay outside the client process. */
|
|
export async function renderLaunchLogTerminalRows(
|
|
result: Extract<DaemonRpcResult, { op: "logs" }>,
|
|
params: Pick<LaunchParams, "head" | "lines">,
|
|
): Promise<string[] | undefined> {
|
|
if (result.terminalRows !== undefined) return result.terminalRows;
|
|
if (result.terminalText === undefined) return undefined;
|
|
return renderTerminalOutputIsolated(result.terminalText, {
|
|
head: params.head ?? false,
|
|
maxRows: Math.min(1_000, Math.floor(params.lines ?? 100)),
|
|
});
|
|
}
|
|
|
|
async function toolDetails(result: DaemonRpcResult, params: LaunchParams): Promise<LaunchToolDetails> {
|
|
switch (result.op) {
|
|
case "start":
|
|
return { op: "start", daemon: result.daemon, timedOut: result.readyTimedOut };
|
|
case "list":
|
|
return { op: "list", daemons: result.daemons };
|
|
case "logs":
|
|
return {
|
|
op: "logs",
|
|
cursor: result.cursor,
|
|
timedOut: result.timedOut,
|
|
state: result.state,
|
|
terminalRows: await renderLaunchLogTerminalRows(result, params).catch(() => undefined),
|
|
};
|
|
case "wait":
|
|
return { op: "wait", daemon: result.daemon, timedOut: result.timedOut, matched: result.matched };
|
|
case "send":
|
|
return { op: "send", daemon: result.daemon };
|
|
case "stop":
|
|
return { op: "stop", daemon: result.daemon };
|
|
case "restart":
|
|
return { op: "restart", daemon: result.daemon };
|
|
case "describe":
|
|
return { op: "describe", daemon: result.daemon, spec: result.spec };
|
|
case "ping":
|
|
case "shutdown":
|
|
throw new ToolError(`Internal daemon result ${result.op} is not tool-visible`);
|
|
}
|
|
}
|
|
|
|
/** Run one broker operation for the calling session's project. */
|
|
export async function executeLaunch(
|
|
session: ToolSession,
|
|
params: LaunchParams,
|
|
signal?: AbortSignal,
|
|
): Promise<AgentToolResult<LaunchToolDetails>> {
|
|
const client = await daemonClientForProject(session.cwd);
|
|
const operation = operationFor(params, session);
|
|
const owner = operation.op === "start" ? operation.owner : undefined;
|
|
const resumedOwner = params.op !== "start" ? (session.getSessionId?.() ?? undefined) : undefined;
|
|
const completionLease = owner
|
|
? registerCompletionSink(session, client, owner)
|
|
: resumedOwner
|
|
? registerCompletionSink(session, client, resumedOwner)
|
|
: undefined;
|
|
try {
|
|
const result = await client.request(operation, signal);
|
|
const sessionOwner = session.getSessionId?.();
|
|
let resumedDaemonFound = false;
|
|
const daemons =
|
|
result.op === "list" ? result.daemons : "daemon" in result && result.daemon ? [result.daemon] : [];
|
|
for (const daemon of daemons) {
|
|
if (!daemon.owner || daemon.owner !== sessionOwner || TERMINAL_STATES[daemon.state]) continue;
|
|
resumedDaemonFound = true;
|
|
if (daemon.owner !== resumedOwner) registerCompletionSink(session, client, daemon.owner)?.retain();
|
|
}
|
|
if (params.op === "list" && resumedOwner && !resumedDaemonFound) completionLease?.reject(true);
|
|
else completionLease?.retain();
|
|
return {
|
|
content: [{ type: "text", text: replaceTabs(toolContent(result, params)) }],
|
|
details: await toolDetails(result, params),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof DaemonBrokerRejectedError && owner) {
|
|
if (completionLease?.hasConcurrentRequest()) {
|
|
completionLease.reject();
|
|
} else {
|
|
try {
|
|
const listed = await client.request({ op: "list" }, signal);
|
|
const ownerStillRunning =
|
|
listed.op === "list" &&
|
|
listed.daemons.some(daemon => daemon.owner === owner && !TERMINAL_STATES[daemon.state]);
|
|
if (ownerStillRunning) completionLease?.retain();
|
|
else completionLease?.reject(true);
|
|
} catch {
|
|
completionLease?.retain();
|
|
}
|
|
}
|
|
} else {
|
|
completionLease?.retain();
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// TUI Renderer (launch half)
|
|
// =============================================================================
|
|
|
|
/** Args shape visible to the renderer, possibly mid-stream (every field optional). */
|
|
export type LaunchRenderArgs = Partial<Omit<LaunchParams, "op">> & { op?: string };
|
|
|
|
function stateColor(state: DaemonState): ThemeColor {
|
|
switch (state) {
|
|
case "running":
|
|
case "ready":
|
|
return "success";
|
|
case "failed":
|
|
return "error";
|
|
case "exited":
|
|
return "muted";
|
|
default:
|
|
return "warning";
|
|
}
|
|
}
|
|
|
|
/** Compact `state · pid · uptime` fragments for the status-line meta slot. */
|
|
function daemonMeta(daemon: DaemonSnapshot, theme: Theme): string[] {
|
|
const meta = [theme.fg(stateColor(daemon.state), daemon.state)];
|
|
if (daemon.readyPending?.length) meta.push(theme.fg("warning", `waiting on ${daemon.readyPending.join("+")}`));
|
|
if (daemon.exitCode !== undefined) {
|
|
meta.push(theme.fg(daemon.exitCode === 0 ? "muted" : "error", `exit ${daemon.exitCode}`));
|
|
} else if (daemon.pid !== undefined) {
|
|
meta.push(`pid ${daemon.pid}`);
|
|
}
|
|
const lifespan = formatDuration((daemon.exitedAt ?? Date.now()) - daemon.startedAt);
|
|
meta.push(daemon.exitedAt === undefined ? `up ${lifespan}` : `ran ${lifespan}`);
|
|
if (daemon.restartCount > 0) meta.push(`restarts ${daemon.restartCount}`);
|
|
if (daemon.detached) meta.push("detached");
|
|
else if (daemon.persist) meta.push("persistent");
|
|
return meta;
|
|
}
|
|
|
|
/** Op-specific call context (command line, log filters, wait condition, send payload). */
|
|
function callMeta(args: LaunchRenderArgs): string[] {
|
|
const meta: string[] = [];
|
|
switch (args.op) {
|
|
case "start":
|
|
if (args.application) meta.push([args.application, ...(args.args ?? [])].join(" "));
|
|
break;
|
|
case "logs":
|
|
if (args.follow) meta.push("follow");
|
|
if (args.grep) meta.push(`grep /${args.grep}/`);
|
|
break;
|
|
case "wait":
|
|
meta.push(args.pattern ? `for /${args.pattern}/` : `for ${args.for ?? "exit"}`);
|
|
break;
|
|
case "send":
|
|
if (args.signal) meta.push(args.signal);
|
|
else if (args.text) meta.push(args.text);
|
|
if (args.keys?.length) meta.push(args.keys.join(" "));
|
|
break;
|
|
}
|
|
return meta.map(entry => previewLine(replaceTabs(entry), TRUNCATE_LENGTHS.SHORT));
|
|
}
|
|
|
|
/** Pending-call frame for launch ops; consumes the spinner while the broker call is live. */
|
|
export function launchRenderCall(args: LaunchRenderArgs, options: RenderResultOptions, theme: Theme): Component {
|
|
const target = args.name ?? args.application;
|
|
const header = renderStatusLine(
|
|
{
|
|
icon: options.spinnerFrame !== undefined ? "running" : "pending",
|
|
spinnerFrame: options.spinnerFrame,
|
|
title: `Launch ${args.op ?? "…"}`,
|
|
description: target ? replaceTabs(target) : undefined,
|
|
meta: callMeta(args),
|
|
},
|
|
theme,
|
|
);
|
|
return new Text(header, 0, 0);
|
|
}
|
|
|
|
/** Result frame: one status header per op, meta from structured details, capped body lines. */
|
|
export function launchRenderResult(
|
|
result: { content: Array<{ type: string; text?: string }>; details?: LaunchToolDetails; isError?: boolean },
|
|
options: RenderResultOptions,
|
|
theme: Theme,
|
|
args?: LaunchRenderArgs,
|
|
): Component {
|
|
const details = result.details;
|
|
const params = args ?? {};
|
|
const op = details?.op ?? params.op;
|
|
const isError = result.isError === true;
|
|
const daemon = details?.daemon;
|
|
const failed = isError || daemon?.state === "failed";
|
|
const text =
|
|
result.content
|
|
?.filter(item => item.type === "text")
|
|
.map(item => item.text ?? "")
|
|
.join("\n") ?? "";
|
|
|
|
const meta: string[] = [];
|
|
const body: string[] = [];
|
|
let description = params.name ?? daemon?.name;
|
|
|
|
if (isError) {
|
|
for (const line of replaceTabs(text.trimEnd()).split("\n")) body.push(theme.fg("error", line));
|
|
} else {
|
|
switch (op) {
|
|
case "start": {
|
|
meta.push(...callMeta(params));
|
|
if (daemon) meta.push(...daemonMeta(daemon, theme));
|
|
if (daemon?.readyMatch) body.push(theme.fg("dim", `log matched: ${replaceTabs(daemon.readyMatch)}`));
|
|
if (daemon?.state === "failed" && daemon.exitReason)
|
|
body.push(theme.fg("error", replaceTabs(daemon.exitReason)));
|
|
if (details?.timedOut) {
|
|
const pending = daemon ? readyPendingSummary(daemon, params.ready) : [];
|
|
body.push(
|
|
theme.fg(
|
|
"warning",
|
|
pending.length > 0
|
|
? `Not ready — ${pending.join("; ")}. Still running.`
|
|
: "Readiness timed out; the process is still running.",
|
|
),
|
|
);
|
|
} else if (params.ready && daemon && daemon.readyAt === undefined && TERMINAL_STATES[daemon.state]) {
|
|
body.push(theme.fg("warning", "Process exited before readiness was observed."));
|
|
}
|
|
break;
|
|
}
|
|
case "send":
|
|
meta.push(...callMeta(params));
|
|
if (daemon) meta.push(...daemonMeta(daemon, theme));
|
|
break;
|
|
case "stop":
|
|
case "restart":
|
|
if (daemon) meta.push(...daemonMeta(daemon, theme));
|
|
break;
|
|
case "wait": {
|
|
meta.push(...callMeta(params));
|
|
if (daemon) meta.push(...daemonMeta(daemon, theme));
|
|
if (details?.matched) body.push(theme.fg("dim", `matched: ${replaceTabs(details.matched)}`));
|
|
if (details?.timedOut) {
|
|
const pending = daemon ? readyPendingSummary(daemon) : [];
|
|
body.push(
|
|
theme.fg(
|
|
"warning",
|
|
pending.length > 0
|
|
? `Wait timed out — still waiting on ${pending.join("; ")}.`
|
|
: "Wait timed out.",
|
|
),
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "list": {
|
|
const daemons = details?.daemons ?? [];
|
|
description = `${daemons.length || "no"} ${pluralize("process", daemons.length)}`;
|
|
for (const item of daemons) {
|
|
body.push(
|
|
`${theme.fg("accent", replaceTabs(item.name))} ${theme.fg("dim", daemonMeta(item, theme).join(theme.sep.dot))}`,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "logs": {
|
|
if (details?.state) meta.push(theme.fg(stateColor(details.state), details.state));
|
|
if (details?.cursor !== undefined) meta.push(`cursor ${details.cursor}`);
|
|
if (details?.timedOut) meta.push(theme.fg("warning", "follow timed out"));
|
|
// Strip the trailing `[name: state; cursor=N]` status suffix `toolContent` appends.
|
|
const logText = text.replace(/\n?\[[^\n]*\]$/, "").trimEnd();
|
|
const terminalRows = details?.terminalRows;
|
|
if (terminalRows) {
|
|
for (const row of terminalRows) body.push(styleTerminalRow(row, theme.getFgAnsi("toolOutput")));
|
|
} else if (logText) {
|
|
for (const line of logText.split("\n")) body.push(theme.fg("toolOutput", replaceTabs(line)));
|
|
}
|
|
break;
|
|
}
|
|
case "describe": {
|
|
if (daemon) meta.push(...daemonMeta(daemon, theme));
|
|
const spec = details?.spec;
|
|
if (spec) {
|
|
body.push(theme.fg("toolOutput", replaceTabs([spec.application, ...spec.args].join(" "))));
|
|
body.push(theme.fg("dim", `cwd ${shortenPath(spec.cwd)}`));
|
|
const flags = [`pty ${spec.pty}`, `restart ${spec.restart}`];
|
|
if (spec.detached) flags.push("detached");
|
|
else if (spec.persist) flags.push("persistent");
|
|
body.push(theme.fg("dim", flags.join(theme.sep.dot)));
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
if (text.trim()) {
|
|
for (const line of replaceTabs(text.trimEnd()).split("\n")) body.push(theme.fg("toolOutput", line));
|
|
}
|
|
}
|
|
}
|
|
|
|
const header = renderStatusLine(
|
|
{
|
|
...(failed
|
|
? { icon: "error" as const }
|
|
: options.isPartial
|
|
? { icon: "pending" as const }
|
|
: { iconOverride: theme.styledSymbol("tool.launch", "accent") }),
|
|
title: `Launch ${op ?? ""}`.trimEnd(),
|
|
description: description ? replaceTabs(description) : undefined,
|
|
meta,
|
|
},
|
|
theme,
|
|
);
|
|
|
|
if (op === "logs") {
|
|
return framedBlock(theme, width => {
|
|
const innerWidth = outputBlockContentWidth(width);
|
|
const rows = body.map(line => truncateToWidth(line, innerWidth));
|
|
return {
|
|
header,
|
|
state: options.isPartial ? "pending" : failed ? "error" : "success",
|
|
sections: [
|
|
{
|
|
label: theme.fg("toolTitle", "Output"),
|
|
lines: capPreviewLines(rows, theme, {
|
|
expanded: options.expanded,
|
|
max: DEFAULT_TERMINAL_PREVIEW_LINES,
|
|
}),
|
|
},
|
|
],
|
|
width,
|
|
};
|
|
});
|
|
}
|
|
|
|
return createCachedComponent(
|
|
() => options.expanded,
|
|
(width, expanded) => {
|
|
let visible = body;
|
|
if (!expanded && op === "list" && body.length > PREVIEW_LIMITS.COLLAPSED_ITEMS) {
|
|
const remaining = body.length - PREVIEW_LIMITS.COLLAPSED_ITEMS;
|
|
visible = [
|
|
...body.slice(0, PREVIEW_LIMITS.COLLAPSED_ITEMS),
|
|
theme.fg("dim", `${formatMoreItems(remaining, "process")} ${formatExpandHint(theme, false, true)}`),
|
|
];
|
|
}
|
|
return [header, ...visible].map(line => truncateToWidth(line, width));
|
|
},
|
|
);
|
|
}
|