Skip to main content

CliRunnerEngine

CliRunnerEngine manages subprocess lifecycle of external CLI Agent tools (such as Claude Code CLI, Codex CLI) via ProcessSupervisor. Supports both regular subprocess mode and PTY terminal mode.

Architecture Diagram

graph TB
Router["AgentRouter / MagiService"] --> Engine["CliRunnerEngine"]

Engine --> Resolve["resolveCliConfig()"]
Engine --> Backend["resolveCliBackendConfig()"]
Engine --> Args["buildCliArgs()"]
Engine --> Env["buildEnv()"]

Engine --> Supervisor["ProcessSupervisor"]

Supervisor --> Decision{"Process mode?"}
Decision -->|"mode: 'child'"| ChildAdapter["ChildAdapter<br/>child_process.spawn"]
Decision -->|"mode: 'pty'"| PtyAdapter["PtyAdapter<br/>node-pty"]

ChildAdapter --> RunRegistry["RunRegistry<br/>run state tracking"]
PtyAdapter --> RunRegistry

Engine --> Parse["parseCliOutput()"]
Engine --> DB["DB persistence<br/>messages + session ID"]
Engine --> IPC["IPC events<br/>agent:event"]

ProcessSupervisor

ProcessSupervisor is the subprocess lifecycle manager.

Interface

interface ProcessSupervisor {
spawn(input: SpawnInput): ManagedRun;
cancel(runId: string, reason?: TerminationReason): void;
cancelScope(scopeKey: string, reason?: TerminationReason): void;
getRecord(runId: string): RunRecord | undefined;
resizePty(runId: string, cols: number, rows: number): boolean;
}

Process Modes

Child Mode

interface SpawnChildInput {
mode: 'child';
argv: string[]; // command and arguments
input?: string; // stdin input
stdinMode?: 'inherit' | 'pipe-open' | 'pipe-closed';
windowsVerbatimArguments?: boolean;
// ...shared fields
}

Uses child_process.spawn to create subprocess with stdin/stdout communication via pipes.

PTY Mode

interface SpawnPtyInput {
mode: 'pty';
shell?: string; // default: powershell.exe (Windows) / /bin/bash (Unix)
args?: string[];
cols?: number; // default 120
rows?: number; // default 30
// ...shared fields
}

Uses node-pty to create pseudoterminal with full terminal interactivity (colors, cursor, line editing).

ManagedRun

interface ManagedRun {
runId: string;
pid?: number;
startedAtMs: number;
stdin?: ManagedRunStdin;
wait: () => Promise<RunExit>;
cancel: (reason?: TerminationReason) => void;
}

interface RunExit {
reason: TerminationReason;
exitCode: number | null;
exitSignal: string | null;
durationMs: number;
stdout: string;
stderr: string;
timedOut: boolean;
noOutputTimedOut: boolean;
}

Termination Reasons

type TerminationReason =
| 'manual-cancel' // user manual cancel
| 'overall-timeout' // total time timeout
| 'no-output-timeout' // no output timeout
| 'spawn-error' // spawn failure
| 'signal' // received signal
| 'exit'; // normal exit

Run State

type RunState = 'starting' | 'running' | 'exiting' | 'exited';

interface RunRecord {
runId: string;
sessionId: string;
backendId: string;
scopeKey?: string;
pid?: number;
startedAtMs: number;
lastOutputAtMs: number;
state: RunState;
terminationReason?: TerminationReason;
exitCode?: number | null;
}

Scope Management

scopeKey groups processes; processes within the same scope can be cancelled in batch:

scopeKey = `cli:${backendId}:${cliSessionId}`
replaceExistingScope = true // new process auto-cancels old ones in same scope

Timeout Handling

Dual timeout mechanism:

  1. Overall timeout (timeoutMs) — process total runtime limit
  2. No-output timeout (noOutputTimeoutMs) — process idle time limit
graph LR
Start["Process start"] --> Timer1["Overall timeout timer<br/>default 300s"]
Start --> Timer2["No-output timer<br/>dynamic compute"]

Timer1 -->|timeout| Kill1["terminate: overall-timeout"]
Timer2 -->|timeout| Kill2["terminate: no-output-timeout"]

Output["output received"] -->|reset| Timer2

No-output timeout calculation (cli-watchdog):

noOutputTimeoutMs = clamp(
overallTimeoutMs * ratio,
minMs,
maxMs,
)
ParameterFresh (first-time)Resume (continuing)
ratio0.80.3
minMs180,000 (3min)60,000 (1min)
maxMs600,000 (10min)180,000 (3min)

Cross-platform Process Termination

kill-tree.ts handles cross-platform process tree termination:

  • Windowstaskkill /F /T /PID (force-terminate process tree)
  • Unix — process group signals: SIGTERM → wait → SIGKILL

CLI Backends

Built-in Backends

Claude Code CLI

{
command: 'claude',
args: ['--output-format', 'json', '--verbose', '--max-turns', '25'],
resumeArgs: ['--output-format', 'json', '--verbose', '--resume', '{sessionId}'],
output: 'json',
input: 'arg',
modelArg: '--model',
sessionArg: '--session-id',
sessionMode: 'always',
}

Codex CLI

{
command: 'codex',
args: ['exec', '--json', '--color', 'never', '--sandbox', 'workspace-write', '--skip-git-repo-check'],
output: 'jsonl',
input: 'arg',
modelArg: '--model',
sessionMode: 'none',
}

Backend Configuration Resolution

function resolveCliBackendConfig(
backendId: string,
overrides?: Partial<CliBackendConfig>,
): ResolvedCliBackend | null;
  1. Look up built-in config by backendId
  2. Apply user override config
  3. Return merged config

CLI Args Construction

function buildCliArgs(options: {
backend: CliBackendConfig;
baseArgs: string[];
modelId?: string;
sessionId?: string;
systemPrompt?: string;
isResume: boolean;
}): string[];

Concatenate in order: baseArgs--model--session-id--append-system-prompt

Output Parsing

function parseCliOutput(
stdout: string,
outputMode: 'json' | 'jsonl' | 'text',
sessionIdFields?: string[],
): { text: string; sessionId?: string; usage?: object };
Output modeParse strategy
jsonParse entire output as JSON, extract result or text field
jsonlParse JSON lines, concatenate content
textUse raw text directly

Session Management

CLI Session ID

CliRunnerEngine maintains session ID for CLI tools that support multi-turn conversations:

sessionMode: 'always' | 'existing' | 'none'
ModeBehavior
alwaysalways use session ID (generate new if missing)
existingonly use existing session ID
nonedo not use session ID

Session ID is stored in chatSessions.cliSessionIds field (grouped by backendId).

PTY Terminal

In PTY mode, terminal data streams in real-time to all renderer windows:

IPC EventPayloadDescription
cli:ptyData{ sessionId, data }terminal output data
cli:ptyExit{ sessionId, exitCode, reason }terminal process exit

Supports terminal resize:

resizePty(dbSessionId: string, cols: number, rows: number): boolean;

IPC Events

EventDescription
agent:event type=userMessageuser message saved
agent:event type=processingexecuting CLI command
agent:event type=assistantMessageCLI output saved
agent:event type=resultexecution result stats
agent:event type=errorerror message
cli:ptyDataPTY terminal data stream
cli:ptyExitPTY process exit

Key Files

FilePathDescription
CliRunnerEngineagent-core/engine/cli/CliRunnerEngine.tsIEngine implementation
cli-backendsagent-core/engine/cli/cli-backends.tsbuilt-in backend configs and resolution
cli-helpersagent-core/engine/cli/cli-helpers.tsargs construction and output parsing
cli-watchdogagent-core/engine/cli/cli-watchdog.tsno-output timeout calculation
cli/indexagent-core/engine/cli/index.tsmodule export
supervisorprocess/supervisor/supervisor.tsProcessSupervisor implementation
child-adapterprocess/supervisor/child-adapter.tschild process adapter
pty-adapterprocess/supervisor/pty-adapter.tsPTY adapter
kill-treeprocess/supervisor/kill-tree.tscross-platform process termination
run-registryprocess/supervisor/run-registry.tsrun state registry
typesprocess/supervisor/types.tstype definitions
CliEngineConfig@shared/contracts/cli-types.tsshared types

All paths relative to packages/desktop/app/main/services/.

Extension Points

  • New CLI backend: add new CliBackendConfig in cli-backends.ts
  • Custom output parsing: add new format in parseCliOutput in cli-helpers.ts
  • Custom timeout strategy: modify CLI_FRESH_WATCHDOG_DEFAULTS / CLI_RESUME_WATCHDOG_DEFAULTS
ModulePathRelationship
EngineDispatcheragent-core/engine/EngineDispatcher.tsengine registration
ProcessSupervisorprocess/supervisor/subprocess management
MagiServiceagent-core/magi/MagiService.tshigh-level orchestration