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

@wkoutre/claude-tmux-runner

v0.7.4

Published

Drive Claude Code interactively through tmux. A programmatic, deterministic replacement for `claude -p` that stays on subscription-window billing.

Downloads

3,015

Readme

claude-tmux-runner

Drive Claude Code interactively through tmux. A programmatic, deterministic replacement for claude -p that stays on subscription-window billing.

Educational purposes only. This project exists solely for educational and learning purposes. It is one demonstration of how Claude Code can be used to build working functionality in response to Anthropic's upcoming billing change. It is not intended for production use or for circumventing Anthropic's billing.

Motivation

On 2026-06-15, Anthropic is moving claude -p and the Agent SDK off subscription-window billing onto a separate metered monthly credit (Max 5x: $100, Max 20x: $200, doesn't roll over). Interactive claude (no -p flag) is explicitly excluded from that change and stays on regular plan quota.

If you've been driving Claude Code programmatically from a daemon (cron jobs, agent orchestrators, CI/CD), claude -p was the obvious choice. After June 15 it's expensive. This package replaces it with interactive claude spawned inside a detached tmux session — same programmatic interface, same N-parallel determinism, billed against your subscription window like any human-driven Claude Code session.

CLI

The package ships a claude-tmux binary that mirrors claude -p so it can be dropped into any script that currently calls claude -p "...".

Install globally

npm install -g @wkoutre/claude-tmux-runner
# or, per-project and invoke via npx:
npm install @wkoutre/claude-tmux-runner

Usage

claude-tmux [options] [prompt]

claude-tmux is a drop-in replacement for claude -p. Any invocation that works against claude -p works the same way against claude-tmux: the wrapper owns a handful of flags explicitly, and any other flag (including new claude flags this wrapper hasn't been updated for yet) is forwarded verbatim to the spawned claude process. See claude --help for the full forwarded-flag set, or use the -- escape hatch to forward arbitrary flags with no schema lookup.

Wrapper-owned flags

| flag | description | |---|---| | [prompt] (positional) | Prompt text (also via --prompt, --input-file, or stdin pipe) | | --prompt <text> | Prompt text (alternative to positional) | | --input-file <path> | Read the prompt from a UTF-8 file (wrapper-only convenience; claude itself has no --input-file). One trailing newline stripped. Mutually exclusive with positional / --prompt / stdin. | | -p, --print | No-op alias for claude -p's --print. claude-tmux always prints. | | --output-format <fmt> | text (default), json, or stream-json. json/stream-json emit a claude -p-shaped {type:'result', ...} event (type, subtype, is_error, api_error_status, session_id, result, stop_reason, num_turns, duration_ms, total_cost_usd, usage, permission_denials, terminal_reason). See "Output-format divergences" below for what differs. | | --model <id> | Model override, e.g. claude-opus-4-7 | | --fallback-model <id> | Emulates claude -p's --fallback-model: if the primary model fails with an overload/unavailable API error (HTTP 529/503/404), the run is retried once with this model (on a fresh session). | | --system-prompt <text> | Forwarded verbatim to claude as --system-prompt (both new and resume) | | --append-system-prompt <text> | Forwarded verbatim to claude as --append-system-prompt | | --effort <level> | low, medium, high, xhigh, or max | | --mcp-config <path> | MCP config JSON file. Repeatable (variadic in claude) | | --plugin-dir <path> | Plugin directory. Repeatable; merged with wrapper-internal dirs | | --settings <file-or-json> | User settings file path or inline JSON; deep-merged with wrapper hook settings | | -r, --resume <session-id> | Continue an existing session (value required) | | -c, --continue | Continue the most-recent session in cwd | | --session-id <uuid> | Use a specific UUID for a new session (default: auto-generated) | | --cwd <path> | Working directory for the claude process | | --timeout <seconds> | Wall-clock cap on the whole run (1..86400). Mirrors claude-p's --timeout. Coexists with --no-progress-timeout; whichever fires first wins. | | --no-progress-timeout <ms> | Max ms without output before timeout (0 disables) | | --no-pre-trust | Skip workspace-trust pre-population | | --claude-bin <path> | Path to the claude binary (default: claude) | | --wrapper-debug | Stream wrapper-internal debug logs to stderr. Renamed from --debug so --debug/-d can forward to claude (see below). | | -v, --version | Print claude's version and exit (relays the spawned claude's --version; fast path, no tmux spawn) | | -h, --help | Show help |

Forwarded flags

Anything not in the wrapper-owned list above is forwarded verbatim to claude. The splitter recognizes the full claude --help flag set (so values are tokenized correctly and the prompt positional is never mistaken for a flag value), then forwards unowned flags as-is. Examples that work unchanged:

--add-dir, --agent, --agents, --allowedTools, --disallowedTools, --allow-dangerously-skip-permissions, --dangerously-skip-permissions, -d/--debug, --debug-file, --disable-slash-commands, --exclude-dynamic-system-prompt-sections, --file, --fork-session, --from-pr, --ide, -n/--name, --permission-mode, --plugin-url, --remote-control, --setting-sources, --strict-mcp-config, --tools, --verbose, -w/--worktree, and more.

Note on --debug: --debug is forwarded to claude as claude's -d, --debug [filter]. Use --wrapper-debug for the wrapper's own internal logging. claude's --debug is an optional-value flag, so a value-less --debug immediately before a bare prompt greedily consumes the prompt as the filter (same as claude -p); put the prompt first or use --debug=filter.

Escape hatch: anything after -- is forwarded to claude with no schema lookup. Useful for new claude flags this wrapper hasn't been updated for:

claude-tmux "do thing" -- --some-brand-new-claude-flag=value

Variadic flags (--add-dir, --tools, --allowedTools, --disallowedTools, --betas, --file, --mcp-config)

These are claude's space-separated variadic (<x...>) options, so --add-dir /a /b /c is consumed as three directories (matching claude -p). Like claude (commander), a variadic is greedy: it consumes every following argument until the next flag or the end. That means a prompt placed after a variadic gets eaten:

claude-tmux --add-dir /a /b "my prompt"   # ✗ "my prompt" becomes a third dir → no prompt
claude-tmux "my prompt" --add-dir /a /b    # ✓ prompt first
claude-tmux --add-dir /a /b --model opus "my prompt"   # ✓ a flag ends the variadic
echo "my prompt" | claude-tmux --add-dir /a /b          # ✓ prompt via stdin

Behavior change: earlier versions consumed only one value per --add-dir/--mcp-config/etc. and left the rest as the prompt. They are now true variadics (claude parity), so the prompt must come first (or via --prompt/stdin) when a variadic is present. --plugin-dir and --plugin-url are not variadic in claude (repeatable single-value), so they are unchanged.

Print-mode flags

claude-tmux drives interactive claude (not -p) to stay on subscription-window billing. Flags that claude --help marks "only works with --print" reach a process that parses them without error but never honors them. Rather than let them no-op silently, the wrapper handles them explicitly:

  • Emulated: --fallback-model is honored by the wrapper (see the wrapper-owned table above) rather than forwarded.
  • Warned (still forwarded, but a one-line claude-tmux: warning: … is written to stderr so a silent no-op never looks like success): --max-budget-usd, --json-schema, --include-hook-events. Wrapper-side emulation of these is planned; until then they are not honored.
  • Rejected (exit 2, structurally incompatible with the single-prompt tmux model): --no-session-persistence, --replay-user-messages, --include-partial-messages, and --input-format stream-json (the text default is fine).
  • --prompt-suggestions: accepted and forwarded, but the print/SDK prompt_suggestion message is not emitted under interactive claude. It is classified as a boolean so it never swallows the prompt positional.

The -- escape hatch is for unknown flags; the policy above still applies to these known flags even when they are forced after --.

Known footguns when forwarding

A few flags don't fit the tmux-wrapping model and the wrapper does not protect you from them — they pass through verbatim, may break, and that's on you:

  • --worktree / -w, --tmux — claude opens its own tmux session, conflicts with the wrapper's
  • --remote-control — long-running interactive mode, not non-interactive
  • --ide — interactive IDE attachment; may or may not work inside the tmux pane

Drop-in semantic differences from claude -p

  • -p/--print: in real claude this is a boolean flag with the prompt as a positional; claude-tmux v0.1.x used -p as a value-bearing prompt shortcut. As of v0.2.0, -p/--print matches claude's semantics (boolean no-op).
  • --system-prompt on resume: forwarded verbatim, matching claude. Earlier versions silently rewrote this to --append-system-prompt on new sessions only; that behavior is gone.

Output-format divergences from claude -p

The {type:'result', ...} event matches claude -p's field shape (verified against a live claude 2.1.156 capture), with these honest exceptions, none of them fabricated:

  • total_cost_usd is always 0. claude-tmux runs claude interactively (no -p), so there is no real per-call charge. claude -p derives cost from a model-pricing table; we do not ship/guess one. Compute cost yourself from usage if you need it.
  • num_turns is always 1. One user turn per run. claude -p counts sub-turns for multi-tool runs; the wrapper does not track that.
  • Omitted fields (the interactive wrapper does not have this data, so it emits nothing rather than a fake value): duration_api_ms, ttft_ms, modelUsage, uuid, fast_mode_state, and the richer usage sub-fields (server_tool_use, service_tier, cache_creation, iterations).
  • stream-json per-line envelope. assistant and user lines are transcoded into claude -p's SDK wire envelope (assistant{type, message, parent_tool_use_id, request_id, session_id, uuid}; user{type, message, parent_tool_use_id, session_id, uuid, timestamp, tool_use_result}), verified against a live capture. Remaining gaps: (a) parent_tool_use_id is always null — for Task sub-agent (isSidechain) lines claude -p sets it to the spawning tool_use id, which the on-disk parentUuid (an entry uuid) does not provide; (b) other line types (rate_limit_event, any system entries) pass through in their on-disk shape rather than being transcoded; (c) the leading {type:'system', subtype:'init'} line is not emitted — it carries claude's full startup state (tool/skill/plugin/mcp inventories, paths) the wrapper cannot reconstruct, and a partial fake would mislead consumers. The final {type:'result'} line matches json mode.
  • stream-json + --fallback-model. If a fallback re-run fires (see --fallback-model above), the primary's pre-error transcript lines are emitted before the fallback's lines, so a consumer sees both runs' lines followed by a single final {type:'result'}. The primary never emits a result line (it errored), so there is exactly one.

Exit codes

| code | meaning | |---|---| | 0 | Success (end_turn) | | 1 | Run failed (non-end_turn terminal reason) | | 2 | Bad args or missing dependency (tmux, claude) | | 124 | Timeout (--no-progress-timeout or --timeout) | | 130 | Interrupted (SIGINT) |

Examples

# Basic text output
claude-tmux "What is 2+2?"

# JSON output — a claude-p-shaped {type:'result', ...} event:
# {"type":"result","subtype":"success","is_error":false,"api_error_status":null,
#  "session_id":"...","result":"...","stop_reason":"end_turn","num_turns":1,
#  "duration_ms":...,"total_cost_usd":0,"usage":{...},"permission_denials":[],
#  "terminal_reason":"completed"}
claude-tmux --output-format json "Summarize this file" < notes.txt

# Stream events as NDJSON: claude's transcript lines, then a final {type:'result'}
# line (same shape as json mode). See "Output-format divergences" for how the
# per-line envelope differs from claude -p.
claude-tmux --output-format stream-json --model claude-opus-4-7 "Explain tmux"

# Continue a prior session
SESSION=$(claude-tmux --output-format json "Start a story" | jq -r .session_id)
claude-tmux --resume "$SESSION" "Continue the story"

# Continue the MOST RECENT session in the current cwd (no session id needed)
claude-tmux --continue "Continue from where we left off"

# Pipe from stdin
echo "List three colors" | claude-tmux --output-format text

# Forward an arbitrary claude flag the wrapper doesn't know about
claude-tmux "do X" -- --some-brand-new-claude-flag=value

# Forward a permission mode + add-dir
claude-tmux --permission-mode plan --add-dir /tmp "do X"

# Wrapper-internal debug logging to stderr
claude-tmux --wrapper-debug "do X"

# Forward claude's own debug filter (claude's -d/--debug)
claude-tmux --debug=api,hooks "do X"

Install

npm install @wkoutre/claude-tmux-runner

System requirements: macOS or Linux, tmux installed and on PATH, the claude binary on PATH (or specify claudeBin in the constructor).

Quickstart

import { ClaudeRunner } from '@wkoutre/claude-tmux-runner';

const runner = new ClaudeRunner({ cwd: process.cwd() });

// New session
const first = await runner.run({
  prompt: 'Who are you?',
  model: 'claude-opus-4-7',
  systemPrompt: 'You are a helpful assistant named Alfred.',
});
console.log(first.result);          // "I'm Alfred, ..."
console.log(first.sessionId);        // UUID — use this to continue the conversation

// Resume the same session for a follow-up
const second = await runner.run({
  prompt: 'What did I just ask you?',
  resumeSessionId: first.sessionId,
});
console.log(second.result);          // "You asked who I am."

API

new ClaudeRunner(options)

| option | type | default | description | |---|---|---|---| | cwd | string | (required) | Working directory the spawned claude runs in. Determines the JSONL path. | | claudeBin | string | 'claude' | Path to the claude binary. | | tmuxPrefix | string | 'claude-runner-' | Prefix for tmux session names. | | logger | Logger | no-op | Custom logger ({ info, warn, debug, error }). | | orphanGcMinAgeSec | number | 30 | Minimum age for gcOrphans() to kill a stale session. | | reapOnProcessExit | boolean | true | Install SIGINT/SIGTERM/SIGHUP handlers (lazily, on the first run()) that call dispose() on this runner before exit — killing its tmux server + the claude inside — then re-raise so the consumer's own handlers/default still run. Stops servers leaking when the host process is killed mid-run. Auto-suppressed under a test runner (VITEST/NODE_ENV=test). | | gcOrphansOnStart | boolean | true | On the first run(), fire a once-per-process sweep that reaps orphaned tmux servers (ctr-tmux-<pid>-*.sock) + stale temp dirs left by a dead prior process — the crash/OOM case signals can't catch. Auto-suppressed under a test runner. | | preTrustCwd | boolean | true | Pre-populate workspace-trust in ~/.claude.json so the TUI doesn't block on the trust dialog. |

runner.run(options): Promise<RunResult>

| option | type | default | description | |---|---|---|---| | prompt | string | (required) | Text pasted into the TUI. | | resumeSessionId | string | — | If set, continues an existing session. Otherwise creates a new one. | | model | string | (claude default) | e.g. 'claude-opus-4-7'. | | systemPrompt | string | — | Forwarded verbatim to claude as --system-prompt (both new and resume sessions). | | appendSystemPrompt | string | — | Forwarded verbatim to claude as --append-system-prompt. | | mcpConfigPaths | readonly string[] | — | Paths to MCP config JSON files (variadic, one --mcp-config <path> per entry). | | mcpConfigPath | string | — | (Deprecated) Single-path back-compat alias for mcpConfigPaths. | | userPluginDirs | readonly string[] | — | User-supplied --plugin-dir paths, merged with wrapper-internal dirs. | | userSettingsJson | string | — | Resolved JSON string for --settings; deep-merged with wrapper hook settings. | | customSessionId | string | — | UUID to use for a new session (default: auto-generated). Ignored when resumeSessionId is set. | | effortLevel | 'low' \| 'medium' \| 'high' \| 'xhigh' \| 'max' | — | Passed as --effort. | | extraArgs | string[] | — | Extra CLI flags appended verbatim. | | noProgressTimeoutMs | number | 60_000 (or 1_200_000 when hook harness creation fails) | Max time without a new JSONL entry before bailing. The hook path's deterministic Stop signal makes a 60s watchdog right; the screen-scrape fallback path bumps to 20min for very long Claude turns that quietly digest large tool outputs. 0 disables. | | onStream | (event) => void | — | Streaming callback fired for each ConversationEvent. Meta entries are filtered before this fires. | | env | Record<string, string> | — | Extra env vars merged into the spawned process. |

RunResult

{
  sessionId: string;        // claude session UUID — pass to resumeSessionId for next turn
  result: string;           // concatenated assistant text from THIS turn only
  durationMs: number;
  tokensIn?: number;
  tokensOut?: number;
  tokensCacheRead?: number;
  tokensCacheWrite?: number;
  firstTurnPrefixTokens?: number;
  terminalReason?: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'interrupted' | 'no_progress_timeout' | 'fatal_api_error' | string;
  apiErrorStatus?: number;  // HTTP status if a terminal api_error was observed
  error?: string;           // set on non-end_turn termination
}

runner.kill(sessionId): Promise<void>

Cancels an in-flight run() for that session UUID. Tears down the tmux session and resolves the pending run() promise with an interrupted result.

runner.killAll(): Promise<string[]>

Kill every tmux session this runner owns. Returns the names killed. Useful in shutdown handlers.

runner.gcOrphans(excludeTmuxNames?): Promise<string[]>

GC for stale sessions on this runner's own socket. Age guard (orphanGcMinAgeSec) prevents killing freshly-spawned in-flight sessions; excludeTmuxNames additionally protects named sessions regardless of age. Scoped to the runner's own socket by design — for cross-process cleanup of a dead process's leftovers, see gcOrphanServers / gcOrphanTmpDirs (run automatically once per process on first run() when gcOrphansOnStart is set).

runner.gcOrphanServers(baseDir?): Promise<string[]>

Reap orphaned tmux servers left by dead processes: for each ctr-tmux-<pid>-<hex>.sock under baseDir (default os.tmpdir()) whose owning pid is dead, kill the server (terminating its claude pane) and unlink the socket. A socket whose pid is still alive is never touched. Returns the reaped socket paths.

runner.gcOrphanTmpDirs(maxAgeMs?, baseDir?): Promise<string[]>

Remove stale claude-runner-<pid>-<hash> hook-harness temp dirs whose owning process is dead and that are older than maxAgeMs (default 6h). Fatal runs preserve their dir for post-mortem; this caps that so dirs don't accumulate. A dir whose pid is still alive is never touched.

runner.dispose(): Promise<void>

Tear down this runner's dedicated tmux server (kill-server + socket unlink) and deregister it from process-exit reaping. Called automatically by the reapOnProcessExit signal handlers; also call it directly when shutting down a long-lived runner.

Lower-level exports

For consumers that need finer-grained control (subscribing to JSONL events without spawning, normalizing JSONL externally, building custom orchestration):

  • EngineJsonlTail — per-file tail with isMeta filter, status tracking, api_error detection
  • JsonlStatusTracker, determineStatus, extractAssistantUsage — JSONL lifecycle helpers (MIT-lifted from ataraxy-labs/opensessions)
  • normalizeLine, parseBuffer — pure JSONL → ConversationEvent normalizer
  • ensureCwdTrusted — write ~/.claude.json trust flag
  • buildWrapperScript — zsh wrapper that sources rc files then execs the target
  • buildInteractiveClaudeArgsclaude CLI argv builder
  • newSession, sendKeys, sendText, capturePane, waitForPane, killAllRunnerSessions — low-level tmux helpers

See src/index.ts for the full re-export list.

Limitations / gotchas

  • macOS + Linux only. Windows isn't supported because tmux isn't a meaningful primitive there. WSL works.
  • tmux must be installed. This package shells out to tmux; without it, every run() call rejects.
  • --system-prompt on resume passes through verbatim. Earlier versions silently dropped it on resume; as of v0.2.0 it's forwarded to claude as-is, matching claude -p semantics. If you re-pass differing text on every turn the prompt cache prefix will invalidate and re-cache; omit the flag on resume to use the session's stored system prompt.
  • Workspace trust is best-effort. Failure to write ~/.claude.json is logged but doesn't abort the spawn. If the file is permission-locked, the first interactive run in a new cwd will hit the trust dialog and time out.
  • onStream filters meta entries. Claude Code occasionally injects isMeta:true entries (e.g. "Continue from where you left off." on certain resume paths) that the TUI hides. We filter them at the normalize layer so they don't pollute your stream.
  • PATH / rc files. When spawned from launchd / systemd / cron, the inherited PATH may not include claude. The default wrapper sources ~/.zprofile / ~/.zshenv / ~/.zshrc before exec. Override via buildWrapperScript's rcFiles option if you use bash or a different shell.

License

MIT. Includes MIT-lifted code from ataraxy-labs/opensessions (see LICENSE for the attribution).