claude-tmux-runner
v0.1.4
Published
Drive Claude Code interactively through tmux. A programmatic, deterministic replacement for `claude -p` that stays on subscription-window billing.
Downloads
744
Maintainers
Readme
claude-tmux-runner
Drive Claude Code interactively through tmux. A programmatic, deterministic replacement for claude -p that stays on subscription-window 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.
How it works
Each run() call:
- Generates or reuses a session UUID
- Injects a hook settings JSON that registers
SessionStart+Stophooks on the spawnedclaudeprocess (used for deterministic TUI-ready and end-of-turn signals) - Spawns a detached
tmux new-session -drunning interactiveclaude --session-id <uuid>(or--resume <uuid>) - Waits for the
SessionStarthook to fire (or for the TUI's❯chevron, if the hook harness couldn't be created) - Pastes the prompt via
tmux load-buffer+paste-buffer(binary-safe) - Presses Enter; retries up to 4 times if the paste-render race eats the keystroke
- Tails the per-session JSONL at
~/.claude/projects/<encoded-cwd>/<uuid>.jsonl - Waits for the
Stophook (or for an assistant entry withstop_reason: 'end_turn'on the fallback path) - Reads token usage / terminal reason from the JSONL (using the
Stophook'stranscript_pathwhen available) - Kills the
tmuxsession - Returns a
RunResult
N concurrent run() calls don't interfere — each gets its own tmux session, its own JSONL path, and its own offset-aware tail.
The hook path is on by default; there's no opt-in flag. If the hook harness can't be created (unwritable tmp dir, etc.), the runner logs a warning and falls back to a screen-scrape EOT path (❯ chevron + paste-render detection + JSONL end_turn watch) that works without hooks.
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 claude-tmux-runner
# or, per-project and invoke via npx:
npm install claude-tmux-runnerUsage
claude-tmux [options] [prompt]| flag | description |
|---|---|
| -p, --prompt <text> | Prompt text (alternative to positional argument) |
| --output-format <fmt> | text (default), json, or stream-json |
| --model <id> | Model override, e.g. claude-opus-4-7 |
| --system-prompt <text> | Append a system prompt (new sessions only) |
| --effort <level> | low, medium, high, xhigh, or max |
| --mcp-config <path> | Path to an MCP config JSON file |
| --resume <session-id> | Continue an existing session |
| --cwd <path> | Working directory for the claude process |
| --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) |
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 | No-progress timeout |
| 130 | Interrupted (SIGINT) |
Examples
# Basic text output
claude-tmux "What is 2+2?"
# JSON output (includes sessionId, durationMs, token counts)
claude-tmux --output-format json "Summarize this file" < notes.txt
# Stream events as NDJSON, then a run-result sentinel
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 .sessionId)
claude-tmux --resume "$SESSION" "Continue the story"
# Pipe from stdin
echo "List three colors" | claude-tmux --output-format textInstall
npm install claude-tmux-runnerSystem requirements: macOS or Linux, tmux installed and on PATH, the claude binary on PATH (or specify claudeBin in the constructor).
Quickstart
import { ClaudeRunner } from '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. |
| 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 | — | Appended via --append-system-prompt. Only honored on new sessions. |
| mcpConfigPath | string | — | Path to an MCP config JSON. |
| 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(): Promise<string[]>
Boot-time GC for tmux sessions left by a crashed prior process. Age guard (orphanGcMinAgeSec) prevents killing freshly-spawned in-flight sessions.
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 withisMetafilter, status tracking, api_error detectionJsonlStatusTracker,determineStatus,extractAssistantUsage— JSONL lifecycle helpers (MIT-lifted from ataraxy-labs/opensessions)normalizeLine,parseBuffer— pure JSONL →ConversationEventnormalizerensureCwdTrusted— write~/.claude.jsontrust flagbuildWrapperScript— zsh wrapper that sources rc files thenexecs the targetbuildInteractiveClaudeArgs—claudeCLI argv buildernewSession,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.
tmuxmust be installed. This package shells out totmux; without it, everyrun()call rejects.- Resume re-uses the prior session's system prompt.
claude --resume <uuid>doesn't accept--append-system-prompt. Set the system prompt on the first turn; subsequent turns inherit it. - Workspace trust is best-effort. Failure to write
~/.claude.jsonis 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. onStreamfilters meta entries. Claude Code occasionally injectsisMeta:trueentries (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/~/.zshrcbefore exec. Override viabuildWrapperScript'srcFilesoption if you use bash or a different shell.
Testing
npm install
npm test # vitest unit tests
bash test/cli-integration.sh # end-to-end CLI smoke tests (requires tmux)The unit test suite covers:
- JSONL normalization (including
isMetafiltering) - The per-file tail (offset handling, debounced drain, lifecycle status, api_error fail-fast)
- The opensessions-lifted status state machine
- Workspace-trust file writes
- Post-run summary extraction
- CLI arg parsing, validation, output formats, and exit codes
The integration test (test/cli-integration.sh) drives the full claude-tmux binary end-to-end with a mock claude binary, verifying the tmux session lifecycle, JSONL tail, and output formatting without a live Anthropic account.
Releasing
Releases are tag-driven. A push of vX.Y.Z to origin triggers the publish workflow at .github/workflows/publish.yml which calls into scripts/publish.sh do-publish for the actual build/test/npm publish steps. CI and local releases use the same script and the same auth path (an npm Granular Access Token, referenced as ${WKOUTRE_NPM_TOKEN} in the project's .npmrc), so behaviour stays in sync.
Standard release (recommended)
npm run release patch # 0.1.1 -> 0.1.2
npm run release minor # 0.1.1 -> 0.2.0
npm run release 0.2.0 # explicit(Equivalent to ./scripts/publish.sh release <bump>.)
The script bumps package.json, runs build + tests, commits release: vX.Y.Z, creates an annotated tag, and pushes the branch + tag. CI handles the npm publish from there.
Direct publish (CI internals, or local emergency)
npm run do-publish(Equivalent to ./scripts/publish.sh do-publish.)
Validates package.json matches the v* tag at HEAD, then runs clean + build + test + npm publish. CI and local both substitute ${WKOUTRE_NPM_TOKEN} into the project's .npmrc at runtime; in CI the workflow maps the NPM_TOKEN repo secret to that env var. No interactive 2FA prompt because the token has the bypass flag set.
One-time setup
The token: generate an npm Granular Access Token at https://www.npmjs.com/settings/<your-user>/tokens. Click "Generate New Token" → "Granular Access Token". Configure:
- Token name:
NPM_TOKEN - Bypass two-factor authentication (2FA): CHECK this box. Required for non-interactive publishes; without it npm prompts for an OTP and the workflow fails with
EOTP. - Allowed IP ranges: leave blank.
- Packages and scopes / Permissions: "Read and write". Scope to
claude-tmux-runneronly. - Organizations / Permissions: "No access".
- Expiration: 90 days is the maximum for read/write tokens. Plan a rotation reminder.
Copy the token immediately (npm shows it once).
For CI: add to GitHub at https://github.com/wkoutre/claude-tmux-runner/settings/secrets/actions as a repository secret named NPM_TOKEN. The publish workflow maps it to WKOUTRE_NPM_TOKEN in the job env so it matches what .npmrc references.
For local: export it from your shell config (e.g. ~/.zsh/conf.d/99-local.zsh):
export WKOUTRE_NPM_TOKEN=npm_xxxx...Open a new shell or source the file. The project's .npmrc reads ${WKOUTRE_NPM_TOKEN} at runtime so npm publish from this checkout will use the token. npm install is unaffected (the auth line only applies to operations that require auth, which install of public packages doesn't).
The WKOUTRE_ prefix is intentional: it keeps the var clear of other npm-publishing tooling (lerna, semantic-release, release-please) that defaults to the generic NPM_TOKEN name.
License
MIT. Includes MIT-lifted code from ataraxy-labs/opensessions (see LICENSE for the attribution).
