Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ dist

# Local scratch notes (do not commit)
docs/wip/
refs
34 changes: 28 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

.PHONY: all build test tests test-unit test-integration test-at test-branch test-all typecheck clean install uninstall dev help
.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

BIN := roboppi

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

# Install locations (override like: make install PREFIX=/opt)
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
INSTALL ?= install
SUDO ?= sudo

all: build ## Build the binary (default)

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

install: build ## Install binary to /usr/local/bin
install -m 755 $(BIN) /usr/local/bin/$(BIN)

uninstall: ## Remove binary from /usr/local/bin
rm -f /usr/local/bin/$(BIN) $(addprefix /usr/local/bin/,$(EXTRA_BINS))
install: build ## Install binary to PREFIX/bin (default: /usr/local/bin; uses sudo if needed)
@if [ -w "$(BINDIR)" ] || [ -w "$(PREFIX)" ]; then \
mkdir -p "$(BINDIR)"; \
$(INSTALL) -m 755 "$(BIN)" "$(BINDIR)/$(BIN)"; \
else \
$(SUDO) mkdir -p "$(BINDIR)"; \
$(SUDO) $(INSTALL) -m 755 "$(BIN)" "$(BINDIR)/$(BIN)"; \
fi

install-user: build ## Install binary to ~/.local/bin
@$(MAKE) install PREFIX="$(HOME)/.local"

uninstall: ## Remove binary from PREFIX/bin (uses sudo if needed)
@if [ -w "$(BINDIR)" ]; then \
rm -f "$(BINDIR)/$(BIN)" $(addprefix $(BINDIR)/,$(EXTRA_BINS)); \
else \
$(SUDO) rm -f "$(BINDIR)/$(BIN)" $(addprefix $(BINDIR)/,$(EXTRA_BINS)); \
fi

uninstall-user: ## Remove binary from ~/.local/bin
@$(MAKE) uninstall PREFIX="$(HOME)/.local"

dev: ## Run in dev mode (no build)
$(BUN) $(BUN_FLAGS) run $(SRC)
Expand Down
185 changes: 185 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"typescript": "^5.7.0"
},
"dependencies": {
"@opentui/core": "0.1.80",
"yaml": "^2.8.2"
}
}
71 changes: 66 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import type { AgentCoreConfig } from "./core/agentcore.js";
import type { LogLevel } from "./core/observability.js";
import { startCoreRuntime } from "./core/core-runtime.js";
import { CoreServerTui } from "./tui/core-server-tui.js";
import { ExecutionBudget } from "./core/execution-budget.js";
import { BackpressureController } from "./core/backpressure.js";
import { CircuitBreakerRegistry } from "./core/circuit-breaker.js";
Expand All @@ -29,6 +30,7 @@ import type { WorkerTask, Job } from "./types/index.js";
interface SharedOptions {
help: boolean;
version: boolean;
tui: boolean | undefined;
concurrency: number | undefined;
rps: number | undefined;
maxCost: number | undefined;
Expand Down Expand Up @@ -56,14 +58,17 @@ const HELP_TEXT = `
roboppi — execution-control runtime for agentic workers

USAGE
roboppi [options] Start Core IPC server mode
roboppi Show help (interactive)
roboppi server [options] Start Core IPC server mode (default: TUI when interactive)
roboppi [options] Start Core IPC server mode (when non-interactive)
roboppi run [options] <instructions...> Run a one-shot worker task
roboppi workflow <workflow.yaml> [options] Run a workflow YAML
roboppi daemon <daemon.yaml> [options] Run daemon mode (resident workflows)
roboppi agent [options] <instructions...> Alias for 'roboppi run'

IPC SERVER MODE
Reads JSON Lines from stdin, writes responses to stdout. Logs go to stderr.
TUI is enabled by default when stderr is a TTY; disable with --no-tui.

RUN MODE OPTIONS
--worker <kind> Worker to use: opencode, claude-code, codex (required)
Expand All @@ -88,6 +93,9 @@ SHARED OPTIONS

--log-level <level> Log level: debug|info|warn|error|fatal (default: info)

--tui Force TUI logs UI (stderr)
--no-tui, --plain Disable TUI; emit JSON logs to stderr

--help, -h Show this help message
--version, -v Show version

Expand Down Expand Up @@ -139,6 +147,7 @@ function parseCliArgs(argv: string[]): CliMode {
const shared: SharedOptions = {
help: false,
version: false,
tui: undefined,
concurrency: undefined,
rps: undefined,
maxCost: undefined,
Expand Down Expand Up @@ -224,6 +233,8 @@ function parseCliArgs(argv: string[]): CliMode {
switch (arg) {
case "--help": case "-h": shared.help = true; break;
case "--version": case "-v": shared.version = true; break;
case "--tui": shared.tui = true; break;
case "--no-tui": case "--plain": shared.tui = false; break;
case "--concurrency": shared.concurrency = nextInt(); break;
case "--rps": shared.rps = nextInt(); break;
case "--max-cost": shared.maxCost = nextInt(); break;
Expand All @@ -246,6 +257,21 @@ function parseCliArgs(argv: string[]): CliMode {
return { mode: "server", opts: shared };
}

function parseEnvBool(value: string | undefined): boolean | undefined {
if (value === undefined) return undefined;
const v = value.trim().toLowerCase();
if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
return undefined;
}

function resolveTuiMode(cliTui: boolean | undefined, envTui: string | undefined): boolean {
if (cliTui !== undefined) return cliTui;
const envVal = parseEnvBool(envTui);
if (envVal !== undefined) return envVal;
return !!process.stderr.isTTY;
}

function buildConfig(opts: SharedOptions): AgentCoreConfig {
const config: AgentCoreConfig = {};
if (opts.concurrency !== undefined || opts.rps !== undefined || opts.maxCost !== undefined) {
Expand Down Expand Up @@ -444,17 +470,37 @@ async function executeRun(opts: SharedOptions, run: RunOptions): Promise<void> {

function startServer(opts: SharedOptions): void {
const config = buildConfig(opts);
startCoreRuntime({
config,
loggerComponent: "cli",
});
const useTui = resolveTuiMode(opts.tui, process.env.ROBOPPI_TUI);
if (useTui && process.stderr.isTTY) {
// In Core stdio IPC mode, stdin is reserved for JSONL; use a passive renderer.
const tui = new CoreServerTui({ title: "roboppi core (IPC server)" });
tui.start();
process.on("exit", () => {
try { tui.stop(); } catch { /* ignore */ }
});
startCoreRuntime({
config,
loggerComponent: "cli",
logSink: tui.logSink,
});
return;
}

startCoreRuntime({ config, loggerComponent: "cli" });
}

// ── Main ──────────────────────────────────────────────────────────

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

// Default: show help in interactive TTY.
// Starting the Core IPC server is explicit via `roboppi server` (or by passing options).
if (argv.length === 0 && process.stdin.isTTY && process.stderr.isTTY) {
console.log(HELP_TEXT);
process.exit(0);
}

if (argv[0] === "workflow") {
const { runWorkflowCli } = await import("./workflow/run.js");
await runWorkflowCli(argv.slice(1));
Expand All @@ -467,6 +513,21 @@ async function main(): Promise<void> {
return;
}

// Explicit server subcommand.
if (argv[0] === "server") {
const parsed = parseCliArgs(argv.slice(1));
if (parsed.opts.help) {
console.log(HELP_TEXT);
process.exit(0);
}
if (parsed.opts.version) {
console.log(`roboppi ${VERSION}`);
process.exit(0);
}
startServer(parsed.opts);
return;
}

// Alias: `roboppi agent ...` == `roboppi run ...`
const parsed = argv[0] === "agent"
? parseCliArgs(["run", ...argv.slice(1)])
Expand Down
4 changes: 2 additions & 2 deletions src/core/agentcore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class AgentCore {
private readonly activePermitsByJob = new Map<UUID, UUID>(); // jobId → permitId
private started = false;

constructor(protocol: IpcProtocol, config?: AgentCoreConfig) {
constructor(protocol: IpcProtocol, config?: AgentCoreConfig, observability?: ObservabilityProvider) {
this.protocol = protocol;

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

const logger = this.observability.createLogger("AgentCore");
Expand Down
8 changes: 5 additions & 3 deletions src/core/core-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { JsonLinesTransport } from "../ipc/json-lines-transport.js";
import { IpcProtocol } from "../ipc/protocol.js";
import { AgentCore, type AgentCoreConfig } from "./agentcore.js";
import { Logger } from "./observability.js";
import { Logger, ObservabilityProvider, type LogSink } from "./observability.js";

import { ProcessManager } from "../worker/process-manager.js";
import { OpenCodeAdapter } from "../worker/adapters/opencode-adapter.js";
Expand All @@ -16,6 +16,7 @@ import { Readable, Writable } from "node:stream";
export interface CoreRuntimeOptions {
config?: AgentCoreConfig;
loggerComponent?: string;
logSink?: LogSink;
}

function isNonInteractive(): boolean {
Expand Down Expand Up @@ -65,7 +66,7 @@ function parseDurationMs(input: string): number {

export function startCoreRuntime(options: CoreRuntimeOptions = {}): void {
const config = options.config;
const logger = new Logger(options.loggerComponent ?? "main", config?.logLevel ?? "info");
const logger = new Logger(options.loggerComponent ?? "main", config?.logLevel ?? "info", options.logSink);

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

const transport = new JsonLinesTransport(stdin, stdout);
const protocol = new IpcProtocol(transport);
const core = new AgentCore(protocol, config);
const observability = new ObservabilityProvider(config?.logLevel ?? "info", options.logSink);
const core = new AgentCore(protocol, config, observability);

// Keepalive output for environments that kill silent subprocesses.
// Enabled by default when non-interactive; can be overridden via env.
Expand Down
17 changes: 13 additions & 4 deletions src/core/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@ export interface LogEntry {
data?: unknown;
}

export type LogSink = (entry: LogEntry) => void;

const defaultLogSink: LogSink = (entry) => {
// Output to stderr (stdout is reserved for IPC)
process.stderr.write(JSON.stringify(entry) + "\n");
};

export class Logger {
private _traceId?: string;

constructor(
private readonly component: string,
private readonly minLevel: LogLevel = "debug",
private readonly sink: LogSink = defaultLogSink,
) {}

setTraceId(traceId: string): void {
Expand Down Expand Up @@ -67,8 +75,7 @@ export class Logger {
if (data !== undefined) {
entry.data = data;
}
// Output to stderr (stdout is reserved for IPC)
process.stderr.write(JSON.stringify(entry) + "\n");
this.sink(entry);
}
}

Expand Down Expand Up @@ -173,13 +180,15 @@ export class MetricsCollector {
export class ObservabilityProvider {
private readonly metrics = new MetricsCollector();
private readonly minLevel: LogLevel;
private readonly sink: LogSink;

constructor(minLevel: LogLevel = "debug") {
constructor(minLevel: LogLevel = "debug", sink?: LogSink) {
this.minLevel = minLevel;
this.sink = sink ?? defaultLogSink;
}

createLogger(component: string): Logger {
return new Logger(component, this.minLevel);
return new Logger(component, this.minLevel, this.sink);
}

getMetrics(): MetricsCollector {
Expand Down
1 change: 1 addition & 0 deletions src/daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ export class Daemon {
executorEnv,
this.shutdownAbortController.signal,
branchContext,
this.supervised,
);
const result = await executor.execute();

Expand Down
Loading