Skip to content

Implement CLIRuntimeBase abstract class #5

@alexey-pelykh

Description

@alexey-pelykh

Context

RemoteClaw runs AI agent CLIs (Claude, Gemini, Codex, OpenCode) as subprocesses and parses their NDJSON streaming output into normalized AgentEvent objects. All four CLI runtimes share ~80% of their logic: spawn a subprocess, parse NDJSON lines from stdout, capture stderr, handle abort signals, manage watchdog timers.

CLIRuntimeBase is the abstract base class that encapsulates this shared subprocess machinery. Each concrete runtime (Claude, Gemini, Codex, OpenCode) extends it and only implements the CLI-specific parts: argument construction, event extraction, and environment setup.

Depends on: #3 (AgentRuntime interface — merged)

Architecture

AgentRuntime (interface, from types.ts)
    ^
    |  implements
    |
CLIRuntimeBase (abstract class — this issue)
    ^
    |  extends
    |
├── ClaudeCliRuntime    (future issue)
├── GeminiCliRuntime    (future issue)
├── CodexCliRuntime     (future issue)
└── OpenCodeCliRuntime  (future issue)

CLIRuntimeBase handles:

  • Subprocess spawning via child_process.spawn()
  • NDJSON line parsing from stdout
  • Async event queue (yields AgentEvent items)
  • Watchdog timer (configurable timeout)
  • Abort signal propagation to child process
  • Stderr capture for error classification
  • Stdin delivery for long prompts

Subclasses handle:

  • buildArgs(params) — construct CLI-specific arguments
  • extractEvent(line) — parse provider-specific NDJSON into AgentEvent
  • buildEnv(params) — construct provider-specific environment variables

File Location

src/middleware/cli-runtime-base.ts (~150 lines estimated)

Implementation Details

Abstract Methods

/** Construct CLI-specific command-line arguments. */
protected abstract buildArgs(params: AgentExecuteParams): string[];

/** Parse a single NDJSON line into an AgentEvent (or null to skip). */
protected abstract extractEvent(line: string): AgentEvent | null;

/** Construct provider-specific environment variables. */
protected abstract buildEnv(params: AgentExecuteParams): Record<string, string>;

Subprocess Spawning

  • Use child_process.spawn() (not exec) for streaming output
  • Set cwd from params.workingDirectory
  • Merge buildEnv(params) with process.env
  • stdio: ["pipe", "pipe", "pipe"] — stdin for prompts, stdout for NDJSON, stderr for errors

Long Prompt Handling

Prompts exceeding 10K characters must be delivered via stdin (not CLI argument) to avoid OS argument length limits:

  • Write prompt to child.stdin, then close stdin
  • Subclasses indicate whether the CLI accepts stdin prompts via a class property or method

NDJSON Parsing

  • Read stdout line-by-line (split on \n)
  • Each line: attempt JSON.parse(), pass to extractEvent()
  • Malformed lines: log warning, skip (not fatal)
  • Yield parsed AgentEvent objects via async generator

Watchdog Timer

  • Configurable timeout (default: 5 minutes, override via constructor)
  • Reset on each NDJSON line received (activity = alive)
  • On timeout: kill child process, yield AgentErrorEvent with timeout info

Abort Signal Propagation

  • Wire params.abortSignal to child.kill('SIGTERM')
  • On abort: yield AgentErrorEvent with abort info, then AgentDoneEvent

Stderr Capture

  • Accumulate stderr output into a buffer
  • Expose to subclasses (or ErrorClassifier) after subprocess exits
  • Don't treat stderr as fatal — some CLIs write progress/debug info to stderr

execute() — Async Generator

The public execute() method implements AgentRuntime.execute() as an async generator:

async *execute(params: AgentExecuteParams): AsyncIterable<AgentEvent> {
    const args = this.buildArgs(params);
    const env = this.buildEnv(params);
    const child = spawn(this.command, args, { cwd: params.workingDirectory, env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"] });

    // ... wire abort signal, watchdog timer, stderr capture ...
    // ... for each NDJSON line from stdout: extractEvent() → yield ...
    // ... on subprocess exit: yield AgentDoneEvent with AgentRunResult ...
}

Constructor

constructor(
    /** CLI command name (e.g., "claude", "gemini", "codex", "opencode"). */
    protected readonly command: string,
    /** Subprocess timeout in milliseconds (default: 5 minutes). */
    protected readonly timeoutMs: number = 300_000,
)

Types Used (from src/middleware/types.ts)

  • AgentRuntime — interface this class implements
  • AgentExecuteParams — input parameter type
  • AgentEvent — discriminated union yielded from execute()
  • AgentRunResult — final summary carried in AgentDoneEvent
  • AgentErrorEvent — emitted on timeout/abort/error

Acceptance Criteria

  • src/middleware/cli-runtime-base.ts exports abstract class CLIRuntimeBase
  • Class implements AgentRuntime interface
  • Three abstract methods: buildArgs(), extractEvent(), buildEnv()
  • Subprocess spawned via child_process.spawn() with configurable command
  • NDJSON stdout parsing with malformed line handling (log + skip)
  • execute() is an async generator yielding AgentEvent objects
  • Long prompts (>10K chars) delivered via stdin
  • Working directory set via spawn() cwd option
  • Stderr captured into a buffer for downstream error classification
  • Abort signal from params.abortSignal kills child process
  • Watchdog timer kills process on inactivity timeout
  • Unit tests for: NDJSON parsing, abort propagation, watchdog timeout, stdin prompt delivery
  • pnpm build passes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions