Skip to content

Commit 8ea38d3

Browse files
authored
Merge pull request #11 from reoring/tui
feat(tui): add workflow and server TUIs
2 parents 972c3e2 + 6e5391d commit 8ea38d3

37 files changed

Lines changed: 2958 additions & 37 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ dist
1010

1111
# Local scratch notes (do not commit)
1212
docs/wip/
13+
refs

‎Makefile‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
.PHONY: all build test tests test-unit test-integration test-at test-branch test-all typecheck clean install uninstall dev help
2+
.PHONY: all build test tests test-unit test-integration test-at test-branch test-all typecheck clean install install-user uninstall uninstall-user dev help
33

44
BIN := roboppi
55

@@ -15,6 +15,12 @@ BUN_FLAGS ?=
1515
# Extra flags passed to `bun build` (e.g. BUN_BUILD_FLAGS=--compile-exec-argv=--smol)
1616
BUN_BUILD_FLAGS ?=
1717

18+
# Install locations (override like: make install PREFIX=/opt)
19+
PREFIX ?= /usr/local
20+
BINDIR ?= $(PREFIX)/bin
21+
INSTALL ?= install
22+
SUDO ?= sudo
23+
1824
all: build ## Build the binary (default)
1925

2026
build: ## Build a single binary (roboppi)
@@ -46,11 +52,27 @@ typecheck: ## Run TypeScript type checker
4652
clean: ## Remove build artifacts
4753
rm -f $(BIN) $(EXTRA_BINS)
4854

49-
install: build ## Install binary to /usr/local/bin
50-
install -m 755 $(BIN) /usr/local/bin/$(BIN)
51-
52-
uninstall: ## Remove binary from /usr/local/bin
53-
rm -f /usr/local/bin/$(BIN) $(addprefix /usr/local/bin/,$(EXTRA_BINS))
55+
install: build ## Install binary to PREFIX/bin (default: /usr/local/bin; uses sudo if needed)
56+
@if [ -w "$(BINDIR)" ] || [ -w "$(PREFIX)" ]; then \
57+
mkdir -p "$(BINDIR)"; \
58+
$(INSTALL) -m 755 "$(BIN)" "$(BINDIR)/$(BIN)"; \
59+
else \
60+
$(SUDO) mkdir -p "$(BINDIR)"; \
61+
$(SUDO) $(INSTALL) -m 755 "$(BIN)" "$(BINDIR)/$(BIN)"; \
62+
fi
63+
64+
install-user: build ## Install binary to ~/.local/bin
65+
@$(MAKE) install PREFIX="$(HOME)/.local"
66+
67+
uninstall: ## Remove binary from PREFIX/bin (uses sudo if needed)
68+
@if [ -w "$(BINDIR)" ]; then \
69+
rm -f "$(BINDIR)/$(BIN)" $(addprefix $(BINDIR)/,$(EXTRA_BINS)); \
70+
else \
71+
$(SUDO) rm -f "$(BINDIR)/$(BIN)" $(addprefix $(BINDIR)/,$(EXTRA_BINS)); \
72+
fi
73+
74+
uninstall-user: ## Remove binary from ~/.local/bin
75+
@$(MAKE) uninstall PREFIX="$(HOME)/.local"
5476

5577
dev: ## Run in dev mode (no build)
5678
$(BUN) $(BUN_FLAGS) run $(SRC)

‎bun.lock‎

Lines changed: 185 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"typescript": "^5.7.0"
2222
},
2323
"dependencies": {
24+
"@opentui/core": "0.1.80",
2425
"yaml": "^2.8.2"
2526
}
2627
}

‎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/daemon/daemon.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,7 @@ export class Daemon {
511511
executorEnv,
512512
this.shutdownAbortController.signal,
513513
branchContext,
514+
this.supervised,
514515
);
515516
const result = await executor.execute();
516517

0 commit comments

Comments
 (0)