New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@0xtiby/spawner

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@0xtiby/spawner

<source media="(prefers-color-scheme: dark)" srcset="./assets/spawner-logo-dark.png"> <source media="(prefers-color-scheme: light)" srcset="./assets/spawner-logo-light.png"> <img alt="spawner" src="./assets/spa

latest
Source
npmnpm
Version
1.3.1
Version published
Maintainers
1
Created
Source
spawner

A unified TypeScript interface to spawn and interact with AI coding CLIs.

npm version license node engine CI status

What Is Spawner?

A single async iterable API to drive Claude Code, Codex CLI, OpenCode, and pi programmatically:

  • You pass SpawnOptions to spawn().
  • You iterate typed CliEvent objects as they stream in (text, tool use, tool result, errors).
  • You get back a structured CliResult -- same shape regardless of which CLI you chose.

It handles process spawning, JSONL stream parsing, session management, error classification, and CLI detection so you can build orchestrators, CI pipelines, and editor integrations without writing per-CLI adapters.

ESM only. Zero runtime dependencies. TypeScript-first.

Used by looper as its CLI driver.

Quick Start

import { spawn } from '@0xtiby/spawner';

const proc = spawn({
  cli: 'claude',
  prompt: 'Refactor the utils module to use named exports',
  cwd: '/path/to/project',
});

for await (const event of proc.events) {
  switch (event.type) {
    case 'text':
      console.log(event.content);
      break;
    case 'tool_use':
      console.log(`Using tool: ${event.tool?.name}`);
      break;
    case 'error':
      console.error(event.content);
      break;
    case 'done':
      console.log('Session:', event.result?.sessionId);
      console.log('Tokens:', event.result?.usage?.totalTokens);
      break;
  }
}

Installation

pnpm add @0xtiby/spawner
# or: npm install @0xtiby/spawner

Prerequisites: Node.js 18+ and at least one supported CLI installed and authenticated:

CLI Feature Parity

Not every CLI supports every SpawnOptions field. This table shows what each adapter passes through and what it silently ignores.

FeatureClaudeCodexOpenCodepi
modelYesYesYesYes
sessionIdYesYesYesYes
continueSessionYesYesYesYes
forkSessionYesYesYesYes
autoApproveYesYes--No-op
effortYesYes--Yes
addDirsYesYes----
ephemeralYesYes--Yes
allowInteractiveToolsYes------
extraArgsYesYesYesYes

Legend: "Yes" = passed to the CLI. "--" = silently ignored. "No-op" = accepted but has no effect (the CLI is already non-interactive).

Pi-specific notes

  • autoApprove is a no-op: pi --mode json is already a headless print mode with no approval prompts.
  • addDirs and allowInteractiveTools are silently ignored so existing SpawnOptions work without crashing.
  • effort is mapped to --thinking <level>. Pi accepts off, minimal, low, medium, high, xhigh natively, so all values pass through; max collapses to xhigh.
  • detect() reports authenticated: true whenever the pi binary exists. Pi resolves credentials per provider at runtime (env vars, config file), so this signal is non-authoritative — real auth failures surface via classifyError when you actually spawn.

Core Concepts

Streaming Events

spawn() returns a CliProcess whose events property is an AsyncIterable<CliEvent>. Each event carries a type field that tells you what happened. Iterate with for await...of to process events as they stream in.

TypeWhen It FiresKey Fields
textThe CLI emits a chunk of assistant textcontent
tool_useThe CLI invokes a tooltool.name, tool.input
tool_resultA tool call completestoolResult.name, toolResult.output, toolResult.error
errorThe CLI reports an inline errorcontent
systemInternal lifecycle signals (session start, step boundaries)content
doneThe process exitsresult (contains CliResult)

Every event also includes timestamp (Unix ms) and raw (the original JSONL line).

Session Management

Use these SpawnOptions fields to control session behavior:

GoalOptions to Set
Start a new session(none -- this is the default)
Resume a session by IDsessionId: '<id>'
Resume the most recent sessioncontinueSession: true
Fork from a specific sessionsessionId: '<id>', forkSession: true
Fork from the most recent sessioncontinueSession: true, forkSession: true

forkSession is additive -- it only takes effect when you also set sessionId or continueSession. A fork creates a new session branched from the specified point, leaving the original session unchanged.

Error Classification

When a CLI process exits with a non-zero code, spawner runs the output through classifyError() and attaches a structured CliError to the result. Each error has a code, a human-readable message, a retryable flag, and an optional retryAfterMs hint. You can use the retryable flag to build automatic retry logic. See the Error Codes section for the full list.

Usage

Spawn a Process

spawn() is the main entry point. It returns a CliProcess with an async iterable of events and a promise that resolves when the process exits.

import { spawn } from '@0xtiby/spawner';

const proc = spawn({
  cli: 'codex',
  prompt: 'Add input validation to the signup handler',
  cwd: '/path/to/repo',
  model: 'o4-mini',
  autoApprove: true,
});

// Stream events as they arrive
for await (const event of proc.events) {
  if (event.type === 'text') {
    process.stdout.write(event.content ?? '');
  }
}

// Or just await the final result
const result = await proc.done;

Resume or Fork a Session

Pass sessionId to continue a previous conversation, or combine it with forkSession to branch off:

// Resume an existing session
const proc = spawn({
  cli: 'claude',
  prompt: 'Now add tests for the changes you just made',
  cwd: '/path/to/repo',
  sessionId: previousResult.sessionId!,
});

// Fork from an existing session (creates a new branch)
const forked = spawn({
  cli: 'claude',
  prompt: 'Try a different approach to the refactor',
  cwd: '/path/to/repo',
  sessionId: previousResult.sessionId!,
  forkSession: true,
});

// Resume the most recent session (no ID needed)
const continued = spawn({
  cli: 'claude',
  prompt: 'What was I working on?',
  cwd: '/path/to/repo',
  continueSession: true,
});

Cancel or Interrupt

Use AbortSignal for external cancellation, or call interrupt() for a graceful shutdown:

const controller = new AbortController();

const proc = spawn({
  cli: 'claude',
  prompt: 'Perform a large refactor',
  cwd: '/path/to/repo',
  abortSignal: controller.signal,
});

// Cancel after 30 seconds
setTimeout(() => controller.abort(), 30_000);

// Or interrupt gracefully (SIGTERM, then SIGKILL after grace period)
const result = await proc.interrupt(5000); // 5s grace period (default)

Detect Installed CLIs

import { detect, detectAll } from '@0xtiby/spawner';

// Check a single CLI
const claude = await detect('claude');
// { installed: true, version: '1.2.3', authenticated: true, binaryPath: '/usr/local/bin/claude' }

// Check all CLIs concurrently
const all = await detectAll();
// { claude: DetectResult, codex: DetectResult, opencode: DetectResult }

Extract Results from Raw Output

If you have captured JSONL output from a previous CLI run, parse it without spawning a process:

import { extract } from '@0xtiby/spawner';

const result = extract({
  cli: 'claude',
  rawOutput: capturedJsonlString,
});

console.log(result.sessionId);
console.log(result.usage);

Query the Model Registry

Models are fetched dynamically from models.dev and cached for 24 hours.

import { listModels, getKnownModels, refreshModels } from '@0xtiby/spawner';

// All models from all providers
const all = await listModels();

// Models for a specific CLI
const claudeModels = await getKnownModels('claude');

// Filter by provider
const openaiModels = await listModels({ provider: 'openai' });

// With offline fallback
const models = await listModels({
  cli: 'claude',
  fallback: [{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic', contextWindow: 200_000, supportsEffort: true }],
});

// Force-refresh the cache
await refreshModels();

API Reference

spawn(options: SpawnOptions): CliProcess

Spawns a CLI process and returns a handle for streaming events and awaiting the result.

SpawnOptions

OptionTypeDefaultDescription
cliCliNamerequiredWhich CLI to spawn: 'claude', 'codex', 'opencode', or 'pi'
promptstringrequiredThe prompt to send to the CLI
cwdstringrequiredWorking directory for the process
modelstringCLI defaultModel identifier to use
sessionIdstring--Resume an existing session
effort'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max' | 'xhigh'--Effort/reasoning level (supported by some models)
autoApproveboolean--Skip tool confirmation prompts. Maps to full permission/sandbox bypass on claude (--dangerously-skip-permissions) and codex (--dangerously-bypass-approvals-and-sandbox). No effect on opencode.
forkSessionboolean--Fork from an existing session
continueSessionboolean--Continue the most recent session
addDirsstring[]--Additional directories to include
ephemeralboolean--Run without persisting session
verboseboolean--Enable debug logging to stderr
allowInteractiveToolsboolean--Allow tools that prompt the user for input (Claude only)
abortSignalAbortSignal--Signal to abort the process
extraArgsstring[]--Additional CLI arguments passed through

CliProcess

Property / MethodTypeDescription
eventsAsyncIterable<CliEvent>Stream of parsed events
pidnumberOS process ID
interrupt(graceMs?)(graceMs?: number) => Promise<CliResult>Gracefully stop the process (SIGTERM, then SIGKILL after graceMs)
donePromise<CliResult>Resolves when the process exits

CliEvent

FieldTypeDescription
typeCliEventType'text', 'tool_use', 'tool_result', 'error', 'system', or 'done'
timestampnumberUnix timestamp (ms)
contentstring?Text content (for text, error, system events)
tool{ name: string; input?: Record<string, unknown> }?Tool invocation details (for tool_use events)
toolResult{ name: string; output?: string; error?: string }?Tool result (for tool_result events)
resultCliResult?Final result (only on done events)
rawstringOriginal JSONL line

CliResult

FieldTypeDescription
exitCodenumberProcess exit code
sessionIdstring | nullSession ID for resumption
usageTokenUsage | nullToken usage and cost
modelstring | nullModel that was used
errorCliError | nullStructured error (if non-zero exit)
durationMsnumberWall-clock duration in milliseconds

TokenUsage

FieldType
inputTokensnumber | null
outputTokensnumber | null
totalTokensnumber | null
costnumber | null

detect(cli: CliName): Promise<DetectResult>

Checks whether a CLI is installed, its version, and authentication status.

detectAll(): Promise<Record<CliName, DetectResult>>

Runs detect() for all four CLIs concurrently.

extract(options: ExtractOptions): CliResult

Parses captured JSONL output into a CliResult without spawning a process. Useful for processing saved output or testing.

classifyError(cli, exitCode, stderr, stdout): CliError

Classifies raw process output into a structured CliError using CLI-specific adapters.

classifyErrorDefault(exitCode, stderr, stdout): CliError

Default error classifier using shared pattern matching. Useful for building custom adapters.

matchSharedPatterns(stderr, stdout)

Low-level pattern matching against the shared error pattern table. Returns { code, retryable, matchedLine } or null.

parseRetryAfterMs(text): number

Extracts a retry-after duration (in ms) from error text. Returns 60000 as a default fallback.

listModels(options?: ListModelsOptions): Promise<KnownModel[]>

Returns models fetched from models.dev, filtered by CLI and/or provider. Results are sorted alphabetically by id. When both cli and provider are set, provider takes precedence.

getKnownModels(cli?: CliName, fallback?: KnownModel[]): Promise<KnownModel[]>

Convenience wrapper over listModels(). Returns models optionally filtered by CLI, with optional fallback on fetch failure.

refreshModels(): Promise<void>

Force-refreshes the in-memory model cache from models.dev. On failure, the existing cache is preserved.

Error Codes

Spawner classifies CLI errors into typed error codes so you can handle them programmatically:

Error CodeRetryableDescription
rate_limitYesRate limited or overloaded -- check retryAfterMs
authNoNot authenticated or invalid credentials
session_not_foundNoSession ID does not exist
model_not_foundNoSpecified model is invalid
context_overflowNoInput exceeds model context window
permission_deniedNoTool use requires confirmation
binary_not_foundNoCLI binary not found on PATH
fatalNoNon-recoverable process error
unknownNoUnrecognized error

Retry Example

import { spawn, type CliResult } from '@0xtiby/spawner';

async function spawnWithRetry(options: Parameters<typeof spawn>[0], maxRetries = 3): Promise<CliResult> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const proc = spawn(options);
    const result = await proc.done;

    if (!result.error?.retryable || attempt === maxRetries) {
      return result;
    }

    const delay = result.error.retryAfterMs ?? 60_000;
    console.log(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
    await new Promise((r) => setTimeout(r, delay));
  }

  throw new Error('unreachable');
}

Models

Models are fetched dynamically from models.dev and cached in memory for 24 hours. There is no static model list -- call listModels() or getKnownModels() to get the current catalog. Use refreshModels() to force a cache refresh.

Architecture

Spawner uses an adapter pattern to normalize differences between CLIs. Each CLI has an adapter that implements:

  • buildCommand() -- Translates SpawnOptions into binary name, arguments, and optional stdin
  • parseLine() -- Parses a JSONL line into normalized CliEvent objects
  • detect() -- Checks installation and authentication
  • classifyError() -- CLI-specific error classification with fallback to shared patterns

The spawn() function orchestrates the full lifecycle: adapter selection, child process spawning, readline-based stream parsing, event queuing via async iterable, accumulator tracking (session ID, token usage, model), and result construction on exit.

Examples

Interactive Chat TUI

examples/chat.ts is a fully working terminal chat app built on spawner. It auto-detects installed CLIs, lets you pick one, then drops you into a streaming chat loop with session continuity.

pnpm tsx examples/chat.ts

Features:

  • CLI selection -- detects installed CLIs, shows versions and auth status, prompts you to pick one
  • Streaming responses -- text streams to stdout in real-time with colored labels
  • Tool-use indicators -- shows which tools the CLI invokes during a response
  • Session continuity -- captures sessionId so follow-up messages continue the conversation
  • Ctrl+C interrupt -- interrupts a streaming response without killing the app
  • Slash commands -- /exit to quit, /new to start a fresh session with a different CLI
  • Error handling -- rate limits, auth failures, and CLI crashes are caught and displayed cleanly

Development

# Install dependencies
pnpm install

# Build
pnpm build

# Run tests (334 tests across 20 files)
pnpm test

# Type-check without emitting
pnpm lint

License

MIT

FAQs

Package last updated on 07 May 2026

Related posts