npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@0xtiby/spawner

v1.2.0

Published

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

Readme

spawner

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

Spawner lets you programmatically drive Claude Code, Codex CLI, and OpenCode through a single API. It handles process spawning, JSONL stream parsing, structured event emission, session management, error classification, and CLI detection -- so you can focus on building tools on top of these CLIs instead of wrestling with their individual quirks.

ESM only. Zero runtime dependencies. TypeScript-first.

Why This Exists

Each AI coding CLI has its own binary, arguments, output format, and error behavior. If you want to build tooling that works across all of them -- orchestrators, CI pipelines, editor integrations -- you need to write adapters for each one. Spawner does that once, correctly, and gives you a clean async iterable of typed events in return.

Quick Start

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

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

for await (const event of process.events) {
  switch (event.type) {
    case 'text':
      console.log(event.content);
      break;
    case 'tool_use':
      console.log(`Using tool: ${event.tool?.name}`);
      break;
    case 'tool_result':
      console.log(`Tool result: ${event.toolResult?.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;
  }
}

const result = await process.done;
console.log(`Exited with code ${result.exitCode} in ${result.durationMs}ms`);

Installation

Prerequisites: Node.js 18+, pnpm (or npm/yarn)

You also need at least one supported CLI installed and authenticated:

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

Usage

Spawning a CLI 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;

Resuming a Session

Pass sessionId to continue a previous conversation:

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

Cancellation with AbortSignal

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);

const result = await proc.done;

Graceful Interruption

interrupt() sends SIGTERM, waits for a grace period, then escalates to SIGKILL:

const result = await proc.interrupt(5000); // 5s grace period (default)

Detecting 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 }

Extracting 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);

Error Classification

Classify raw stderr/stdout into structured error codes:

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

const error = classifyError('claude', 1, 'Rate limit exceeded. Try again in 30 seconds.', '');
// {
//   code: 'rate_limit',
//   message: 'Rate limit exceeded. Try again in 30 seconds.',
//   retryable: true,
//   retryAfterMs: 30000,
//   raw: '...'
// }

Querying 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

| Option | Type | Default | Description | |---|---|---|---| | cli | CliName | required | Which CLI to spawn: 'claude', 'codex', or 'opencode' | | prompt | string | required | The prompt to send to the CLI | | cwd | string | required | Working directory for the process | | model | string | CLI default | Model identifier to use | | sessionId | string | -- | Resume an existing session | | effort | 'low' \| 'medium' \| 'high' \| 'max' | -- | Effort level (supported by some models) | | autoApprove | boolean | -- | Skip tool confirmation prompts | | forkSession | boolean | -- | Fork from an existing session | | continueSession | boolean | -- | Continue the most recent session | | addDirs | string[] | -- | Additional directories to include | | ephemeral | boolean | -- | Run without persisting session | | verbose | boolean | -- | Enable debug logging to stderr | | abortSignal | AbortSignal | -- | Signal to abort the process | | extraArgs | string[] | -- | Additional CLI arguments passed through |

CliProcess

| Property / Method | Type | Description | |---|---|---| | events | AsyncIterable<CliEvent> | Stream of parsed events | | pid | number | OS process ID | | interrupt(graceMs?) | (graceMs?: number) => Promise<CliResult> | Gracefully stop the process (SIGTERM, then SIGKILL after graceMs) | | done | Promise<CliResult> | Resolves when the process exits |

CliEvent

| Field | Type | Description | |---|---|---| | type | CliEventType | 'text', 'tool_use', 'tool_result', 'error', 'system', or 'done' | | timestamp | number | Unix timestamp (ms) | | content | string? | 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) | | result | CliResult? | Final result (only on done events) | | raw | string | Original JSONL line |

CliResult

| Field | Type | Description | |---|---|---| | exitCode | number | Process exit code | | sessionId | string \| null | Session ID for resumption | | usage | TokenUsage \| null | Token usage and cost | | model | string \| null | Model that was used | | error | CliError \| null | Structured error (if non-zero exit) | | durationMs | number | Wall-clock duration in milliseconds |

TokenUsage

| Field | Type | |---|---| | inputTokens | number \| null | | outputTokens | number \| null | | totalTokens | number \| null | | cost | number \| 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 three 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 Handling

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

| Error Code | Retryable | Description | |---|---|---| | rate_limit | Yes | Rate limited or overloaded -- check retryAfterMs | | auth | No | Not authenticated or invalid credentials | | session_not_found | No | Session ID does not exist | | model_not_found | No | Specified model is invalid | | context_overflow | No | Input exceeds model context window | | permission_denied | No | Tool use requires confirmation | | binary_not_found | No | CLI binary not found on PATH | | fatal | No | Non-recoverable process error | | unknown | No | Unrecognized 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.

SpawnOptions
    |
    v
[ Adapter Selection ] --> buildCommand() --> child_process.spawn()
    |                                              |
    |                                        stdout (JSONL)
    |                                              |
    v                                              v
[ CLI Adapter ]                           [ Stream Parser ]
  - parseLine()                              - readline
  - classifyError()                          - accumulator
                                                   |
                                                   v
                                          [ Event Queue ]
                                        (AsyncIterable<CliEvent>)
                                                   |
                                                   v
                                             Your Code

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