Skip to content

Commit 6e5391d

Browse files
committed
feat(cli): add optional server TUI log view
1 parent 2e28b91 commit 6e5391d

6 files changed

Lines changed: 252 additions & 14 deletions

File tree

‎src/cli.ts‎

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import type { AgentCoreConfig } from "./core/agentcore.js";
1212
import type { LogLevel } from "./core/observability.js";
1313
import { startCoreRuntime } from "./core/core-runtime.js";
14+
import { CoreServerTui } from "./tui/core-server-tui.js";
1415
import { ExecutionBudget } from "./core/execution-budget.js";
1516
import { BackpressureController } from "./core/backpressure.js";
1617
import { CircuitBreakerRegistry } from "./core/circuit-breaker.js";
@@ -29,6 +30,7 @@ import type { WorkerTask, Job } from "./types/index.js";
2930
interface SharedOptions {
3031
help: boolean;
3132
version: boolean;
33+
tui: boolean | undefined;
3234
concurrency: number | undefined;
3335
rps: number | undefined;
3436
maxCost: number | undefined;
@@ -56,14 +58,17 @@ const HELP_TEXT = `
5658
roboppi — execution-control runtime for agentic workers
5759
5860
USAGE
59-
roboppi [options] Start Core IPC server mode
61+
roboppi Show help (interactive)
62+
roboppi server [options] Start Core IPC server mode (default: TUI when interactive)
63+
roboppi [options] Start Core IPC server mode (when non-interactive)
6064
roboppi run [options] <instructions...> Run a one-shot worker task
6165
roboppi workflow <workflow.yaml> [options] Run a workflow YAML
6266
roboppi daemon <daemon.yaml> [options] Run daemon mode (resident workflows)
6367
roboppi agent [options] <instructions...> Alias for 'roboppi run'
6468
6569
IPC SERVER MODE
6670
Reads JSON Lines from stdin, writes responses to stdout. Logs go to stderr.
71+
TUI is enabled by default when stderr is a TTY; disable with --no-tui.
6772
6873
RUN MODE OPTIONS
6974
--worker <kind> Worker to use: opencode, claude-code, codex (required)
@@ -88,6 +93,9 @@ SHARED OPTIONS
8893
8994
--log-level <level> Log level: debug|info|warn|error|fatal (default: info)
9095
96+
--tui Force TUI logs UI (stderr)
97+
--no-tui, --plain Disable TUI; emit JSON logs to stderr
98+
9199
--help, -h Show this help message
92100
--version, -v Show version
93101
@@ -139,6 +147,7 @@ function parseCliArgs(argv: string[]): CliMode {
139147
const shared: SharedOptions = {
140148
help: false,
141149
version: false,
150+
tui: undefined,
142151
concurrency: undefined,
143152
rps: undefined,
144153
maxCost: undefined,
@@ -224,6 +233,8 @@ function parseCliArgs(argv: string[]): CliMode {
224233
switch (arg) {
225234
case "--help": case "-h": shared.help = true; break;
226235
case "--version": case "-v": shared.version = true; break;
236+
case "--tui": shared.tui = true; break;
237+
case "--no-tui": case "--plain": shared.tui = false; break;
227238
case "--concurrency": shared.concurrency = nextInt(); break;
228239
case "--rps": shared.rps = nextInt(); break;
229240
case "--max-cost": shared.maxCost = nextInt(); break;
@@ -246,6 +257,21 @@ function parseCliArgs(argv: string[]): CliMode {
246257
return { mode: "server", opts: shared };
247258
}
248259

260+
function parseEnvBool(value: string | undefined): boolean | undefined {
261+
if (value === undefined) return undefined;
262+
const v = value.trim().toLowerCase();
263+
if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
264+
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
265+
return undefined;
266+
}
267+
268+
function resolveTuiMode(cliTui: boolean | undefined, envTui: string | undefined): boolean {
269+
if (cliTui !== undefined) return cliTui;
270+
const envVal = parseEnvBool(envTui);
271+
if (envVal !== undefined) return envVal;
272+
return !!process.stderr.isTTY;
273+
}
274+
249275
function buildConfig(opts: SharedOptions): AgentCoreConfig {
250276
const config: AgentCoreConfig = {};
251277
if (opts.concurrency !== undefined || opts.rps !== undefined || opts.maxCost !== undefined) {
@@ -444,17 +470,37 @@ async function executeRun(opts: SharedOptions, run: RunOptions): Promise<void> {
444470

445471
function startServer(opts: SharedOptions): void {
446472
const config = buildConfig(opts);
447-
startCoreRuntime({
448-
config,
449-
loggerComponent: "cli",
450-
});
473+
const useTui = resolveTuiMode(opts.tui, process.env.ROBOPPI_TUI);
474+
if (useTui && process.stderr.isTTY) {
475+
// In Core stdio IPC mode, stdin is reserved for JSONL; use a passive renderer.
476+
const tui = new CoreServerTui({ title: "roboppi core (IPC server)" });
477+
tui.start();
478+
process.on("exit", () => {
479+
try { tui.stop(); } catch { /* ignore */ }
480+
});
481+
startCoreRuntime({
482+
config,
483+
loggerComponent: "cli",
484+
logSink: tui.logSink,
485+
});
486+
return;
487+
}
488+
489+
startCoreRuntime({ config, loggerComponent: "cli" });
451490
}
452491

453492
// ── Main ──────────────────────────────────────────────────────────
454493

455494
async function main(): Promise<void> {
456495
const argv = process.argv.slice(2);
457496

497+
// Default: show help in interactive TTY.
498+
// Starting the Core IPC server is explicit via `roboppi server` (or by passing options).
499+
if (argv.length === 0 && process.stdin.isTTY && process.stderr.isTTY) {
500+
console.log(HELP_TEXT);
501+
process.exit(0);
502+
}
503+
458504
if (argv[0] === "workflow") {
459505
const { runWorkflowCli } = await import("./workflow/run.js");
460506
await runWorkflowCli(argv.slice(1));
@@ -467,6 +513,21 @@ async function main(): Promise<void> {
467513
return;
468514
}
469515

516+
// Explicit server subcommand.
517+
if (argv[0] === "server") {
518+
const parsed = parseCliArgs(argv.slice(1));
519+
if (parsed.opts.help) {
520+
console.log(HELP_TEXT);
521+
process.exit(0);
522+
}
523+
if (parsed.opts.version) {
524+
console.log(`roboppi ${VERSION}`);
525+
process.exit(0);
526+
}
527+
startServer(parsed.opts);
528+
return;
529+
}
530+
470531
// Alias: `roboppi agent ...` == `roboppi run ...`
471532
const parsed = argv[0] === "agent"
472533
? parseCliArgs(["run", ...argv.slice(1)])

‎src/core/agentcore.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export class AgentCore {
6464
private readonly activePermitsByJob = new Map<UUID, UUID>(); // jobId → permitId
6565
private started = false;
6666

67-
constructor(protocol: IpcProtocol, config?: AgentCoreConfig) {
67+
constructor(protocol: IpcProtocol, config?: AgentCoreConfig, observability?: ObservabilityProvider) {
6868
this.protocol = protocol;
6969

7070
// Initialize core components
@@ -75,7 +75,7 @@ export class AgentCore {
7575
this.permitGate = new PermitGate(this.budget, this.circuitBreakers, this.backpressure);
7676
this.watchdog = new Watchdog(config?.watchdog);
7777
this.escalation = new EscalationManager(config?.escalation);
78-
this.observability = new ObservabilityProvider(config?.logLevel);
78+
this.observability = observability ?? new ObservabilityProvider(config?.logLevel);
7979
this.workerGateway = new WorkerDelegationGateway();
8080

8181
const logger = this.observability.createLogger("AgentCore");

‎src/core/core-runtime.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { JsonLinesTransport } from "../ipc/json-lines-transport.js";
22
import { IpcProtocol } from "../ipc/protocol.js";
33
import { AgentCore, type AgentCoreConfig } from "./agentcore.js";
4-
import { Logger } from "./observability.js";
4+
import { Logger, ObservabilityProvider, type LogSink } from "./observability.js";
55

66
import { ProcessManager } from "../worker/process-manager.js";
77
import { OpenCodeAdapter } from "../worker/adapters/opencode-adapter.js";
@@ -16,6 +16,7 @@ import { Readable, Writable } from "node:stream";
1616
export interface CoreRuntimeOptions {
1717
config?: AgentCoreConfig;
1818
loggerComponent?: string;
19+
logSink?: LogSink;
1920
}
2021

2122
function isNonInteractive(): boolean {
@@ -65,7 +66,7 @@ function parseDurationMs(input: string): number {
6566

6667
export function startCoreRuntime(options: CoreRuntimeOptions = {}): void {
6768
const config = options.config;
68-
const logger = new Logger(options.loggerComponent ?? "main", config?.logLevel ?? "info");
69+
const logger = new Logger(options.loggerComponent ?? "main", config?.logLevel ?? "info", options.logSink);
6970

7071
// Mark this process as the Core runtime for logging/diagnostics.
7172
if (!process.env.ROBOPPI_COMPONENT) process.env.ROBOPPI_COMPONENT = "core";
@@ -131,7 +132,8 @@ export function startCoreRuntime(options: CoreRuntimeOptions = {}): void {
131132

132133
const transport = new JsonLinesTransport(stdin, stdout);
133134
const protocol = new IpcProtocol(transport);
134-
const core = new AgentCore(protocol, config);
135+
const observability = new ObservabilityProvider(config?.logLevel ?? "info", options.logSink);
136+
const core = new AgentCore(protocol, config, observability);
135137

136138
// Keepalive output for environments that kill silent subprocesses.
137139
// Enabled by default when non-interactive; can be overridden via env.

‎src/core/observability.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,20 @@ export interface LogEntry {
1919
data?: unknown;
2020
}
2121

22+
export type LogSink = (entry: LogEntry) => void;
23+
24+
const defaultLogSink: LogSink = (entry) => {
25+
// Output to stderr (stdout is reserved for IPC)
26+
process.stderr.write(JSON.stringify(entry) + "\n");
27+
};
28+
2229
export class Logger {
2330
private _traceId?: string;
2431

2532
constructor(
2633
private readonly component: string,
2734
private readonly minLevel: LogLevel = "debug",
35+
private readonly sink: LogSink = defaultLogSink,
2836
) {}
2937

3038
setTraceId(traceId: string): void {
@@ -67,8 +75,7 @@ export class Logger {
6775
if (data !== undefined) {
6876
entry.data = data;
6977
}
70-
// Output to stderr (stdout is reserved for IPC)
71-
process.stderr.write(JSON.stringify(entry) + "\n");
78+
this.sink(entry);
7279
}
7380
}
7481

@@ -173,13 +180,15 @@ export class MetricsCollector {
173180
export class ObservabilityProvider {
174181
private readonly metrics = new MetricsCollector();
175182
private readonly minLevel: LogLevel;
183+
private readonly sink: LogSink;
176184

177-
constructor(minLevel: LogLevel = "debug") {
185+
constructor(minLevel: LogLevel = "debug", sink?: LogSink) {
178186
this.minLevel = minLevel;
187+
this.sink = sink ?? defaultLogSink;
179188
}
180189

181190
createLogger(component: string): Logger {
182-
return new Logger(component, this.minLevel);
191+
return new Logger(component, this.minLevel, this.sink);
183192
}
184193

185194
getMetrics(): MetricsCollector {

‎src/tui/core-server-tui.ts‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { LogEntry, LogSink } from "../core/observability.js";
2+
import { RingBuffer } from "./ring-buffer.js";
3+
import type { TuiRenderer } from "./opentui-platform.js";
4+
import { PassiveAnsiRenderer } from "./passive-ansi-renderer.js";
5+
6+
function pad2(n: number): string {
7+
return n < 10 ? `0${n}` : String(n);
8+
}
9+
10+
function formatTime(ts: number): string {
11+
const d = new Date(ts);
12+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
13+
}
14+
15+
function safeJson(v: unknown): string {
16+
try {
17+
return JSON.stringify(v);
18+
} catch {
19+
return "[unserializable]";
20+
}
21+
}
22+
23+
function truncate(s: string, max: number): string {
24+
if (max <= 0) return "";
25+
if (s.length <= max) return s;
26+
if (max <= 3) return s.slice(0, max);
27+
return s.slice(0, max - 3) + "...";
28+
}
29+
30+
function levelColor(level: string): string {
31+
switch (level) {
32+
case "debug":
33+
return "\x1b[90m";
34+
case "info":
35+
return "\x1b[36m";
36+
case "warn":
37+
return "\x1b[33m";
38+
case "error":
39+
case "fatal":
40+
return "\x1b[31m";
41+
default:
42+
return "";
43+
}
44+
}
45+
46+
function formatLogLine(entry: LogEntry, width: number): string {
47+
const t = formatTime(entry.timestamp);
48+
const lvl = entry.level;
49+
const lvlColored = `${levelColor(lvl)}${lvl}\x1b[0m`;
50+
const base = `${t} ${lvlColored} ${entry.component}: ${entry.message}`;
51+
const data = entry.data !== undefined ? ` ${truncate(safeJson(entry.data), Math.max(0, width - base.length - 1))}` : "";
52+
return truncate(base + data, width);
53+
}
54+
55+
export interface CoreServerTuiOptions {
56+
title?: string;
57+
renderer?: TuiRenderer;
58+
}
59+
60+
export class CoreServerTui {
61+
private readonly renderer: TuiRenderer;
62+
private readonly title: string;
63+
64+
private readonly startedAt = Date.now();
65+
private readonly logs = new RingBuffer<string>({ maxLines: 3000, maxBytes: 1024 * 1024 });
66+
private renderTimer: ReturnType<typeof setInterval> | null = null;
67+
private dirty = true;
68+
69+
private transport: string = "stdio";
70+
71+
constructor(opts: CoreServerTuiOptions = {}) {
72+
this.renderer = opts.renderer ?? new PassiveAnsiRenderer();
73+
this.title = opts.title ?? "roboppi core";
74+
}
75+
76+
get logSink(): LogSink {
77+
return (entry) => {
78+
const width = (process.stderr.columns ?? 120);
79+
this.logs.push(formatLogLine(entry, width));
80+
81+
// Best-effort: infer transport from the startup log.
82+
if (entry.message.includes("awaiting IPC messages")) {
83+
const data = entry.data;
84+
if (data && typeof data === "object") {
85+
const transport = (data as Record<string, unknown>).transport;
86+
if (typeof transport === "string") this.transport = transport;
87+
}
88+
}
89+
90+
this.dirty = true;
91+
};
92+
}
93+
94+
start(): void {
95+
this.renderer.start();
96+
97+
this.renderTimer = setInterval(() => {
98+
if (!this.dirty) return;
99+
this.dirty = false;
100+
this.renderFrame();
101+
}, 100);
102+
103+
this.renderFrame();
104+
}
105+
106+
stop(): void {
107+
if (this.renderTimer) {
108+
clearInterval(this.renderTimer);
109+
this.renderTimer = null;
110+
}
111+
this.renderer.destroy();
112+
}
113+
114+
private renderFrame(): void {
115+
const columns = process.stderr.columns ?? 80;
116+
const rows = process.stderr.rows ?? 24;
117+
118+
const uptimeS = Math.floor((Date.now() - this.startedAt) / 1000);
119+
const header1 = `\x1b[1m${this.title}\x1b[0m transport=${this.transport} uptime=${uptimeS}s`;
120+
const header2 = `stdin/stdout: JSONL IPC (server) logs: TUI (stderr) help: --help stop: Ctrl+C plain: --no-tui`;
121+
const sep = "-".repeat(Math.max(0, Math.min(columns, 120)));
122+
123+
const headerLines = [truncate(header1, columns), truncate(header2, columns), sep];
124+
const bodyHeight = Math.max(0, rows - headerLines.length - 1);
125+
126+
const lines = this.logs.lines();
127+
const visible = lines.slice(Math.max(0, lines.length - bodyHeight));
128+
const padded: string[] = [];
129+
for (const line of visible) padded.push(truncate(line, columns));
130+
while (padded.length < bodyHeight) padded.unshift("");
131+
132+
const out = headerLines.join("\n") + "\n" + padded.join("\n") + "\n";
133+
this.renderer.render(out);
134+
}
135+
}

0 commit comments

Comments
 (0)