Initial scaffold: claude-print PTY wrapper for subscription billing

This commit is contained in:
jedarden 2026-06-07 10:30:31 -04:00
commit 134af23bbf
3 changed files with 197 additions and 0 deletions

13
README.md Normal file
View file

@ -0,0 +1,13 @@
# claude-print
Drop-in replacement for `claude -p` (print/headless mode) that drives the Claude Code interactive TUI via PTY — preserving subscription billing after the June 15, 2026 Agent SDK credit split.
## Why this exists
Starting June 15, 2026, Anthropic separates `claude -p` (headless) into a separate Agent SDK credit pool ($100$200/month on Max plans). Only the interactive TUI (`cc_entrypoint=cli`) continues drawing from the unlimited subscription. `claude-print` wraps the interactive TUI in a PTY so callers get `claude -p` wire-compatible output while billing against the subscription.
## Structure
- `docs/notes/` — design decisions, constraints, integration details
- `docs/research/` — prior art, billing change references, PTY/hook patterns
- `docs/plan/plan.md` — complete implementation plan

View file

@ -0,0 +1,36 @@
# Billing Context
## The June 15, 2026 Split
Anthropic's billing header field `cc_entrypoint` determines which pool a request draws from:
- `cc_entrypoint=cli` → interactive TUI → unlimited subscription
- `cc_entrypoint=sdk-cli``claude -p` / Agent SDK → monthly credit pool
Credit pool sizes: Pro $20/mo, Max 5x $100/mo, Max 20x $200/mo. No rollover.
`claude -p` is currently misclassified as `sdk-cli` even for subscription users (GitHub issue #59105 — acknowledged by Anthropic, not fixed). The June 15 change formalizes this split rather than fixing the classification.
## Why PTY Preserves `cli` Billing
Running `claude` under a real PTY (via `forkpty`) produces `cc_entrypoint=cli` because:
1. `claude` detects it has a real TTY on stdout
2. It enters interactive/TUI mode
3. The billing header is set at startup based on the entrypoint mode
Any wrapper that provides a PTY inherits the `cli` classification. Screen-scraping and hook-based approaches extract the response without changing the billing header.
## Prior Art Repos
| Repo | Approach | Billing |
|------|----------|---------|
| `smithersai/claude-p` | Zig + zmux PTY + Stop hook | cli ✓ |
| `hristo2612/jinn` | Node.js + node-pty + hook relay | cli ✓ |
| `halfwhey/claudraband` | Shell + tmux persistent sessions | cli ✓ |
| `npow/claude-relay` | Wraps `claude -p` | sdk-cli ✗ |
## NEEDLE Integration Context
The `jedarden/NEEDLE` repo has `plugins/claude-interactive/` — a Python PTY wrapper added 2026-05-16. It uses idle-timeout completion detection and `pyte` screen parsing. `claude-print` is the productionized, standalone version of that plugin with Stop hook completion and real token counting.
The NEEDLE agent config (`claude-print.yaml`) will replace `claude-anthropic-sonnet.yaml` for workers that should bill against the subscription.

148
docs/plan/plan.md Normal file
View file

@ -0,0 +1,148 @@
# claude-print Plan
## Overview
Drop-in replacement for `claude -p` that drives the Claude Code interactive TUI via PTY, emitting wire-compatible output while billing against the subscription (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
## Background
Anthropic's June 15, 2026 billing split:
- `claude -p` / Agent SDK → separate monthly credit pool ($100/mo Max 5x, $200/mo Max 20x)
- Interactive TUI (`claude`) → continues consuming unlimited subscription
The key mechanism: the interactive TUI sends `cc_entrypoint=cli` in the billing header. Any wrapper that gives `claude` a real PTY inherits that classification. Screen-scraping or hook-based completion detection extracts the response and emits it in `claude -p` wire format.
## Prior Art
### `jedarden/NEEDLE``plugins/claude-interactive`
Python script (~300 lines). PTY via `pty.openpty()` + `os.fork()`. Completion detection via 30s idle timeout after seeing `●` bullet. Response extraction via `pyte` virtual terminal screen parse. Token counts always zero. Single commit (2026-05-16). **Source for initial implementation.**
### `smithersai/claude-p`
Zig binary, zmux PTY library. Uses `SessionStart` + `Stop` hooks injected via `--settings` for authoritative completion detection. Reads token counts from JSONL transcript. Wire-compatible with `claude -p`. ~50200ms overhead. **Source for Stop hook pattern.**
### `hristo2612/jinn`
Node.js, node-pty. Hook relay via HTTP loopback with shared secret. Intercepts SSE stream via `ANTHROPIC_BASE_URL` proxy. More moving parts; better for persistent session keep-alive use cases.
## Architecture
```
caller
│ prompt (stdin or arg)
claude-print
├── PTY spawner forkpty() → claude --dangerously-skip-permissions
├── Terminal emu responds to DA1/DA2/DSR/XTVERSION probes (Ink requirement)
├── Startup seq wait for burst → CR (trust dismiss) → bracketed-paste prompt
├── Stop hook per-run ~/.config/claude-print/<pid>/settings.json overlay
│ hook writes {session_id, transcript_path} to named pipe
├── Transcript reads ~/.claude/projects/**/<session>.jsonl for token counts
└── Emitter emits stream-json / json / text to stdout (claude -p compat)
```
## Components
### 1. PTY Spawner
- `pty.openpty()` + `os.fork()` + `os.execvp('claude', [...])`
- Forwards `--model`, `--max-turns`, `--allowedTools`, `--dangerously-skip-permissions`
- Sets PTY window size from `/dev/tty` or defaults (220×50)
### 2. Terminal Emulator (Ink probe responder)
- DA1 (`ESC[c`) → `ESC[?6c`
- DA2 (`ESC[>0c`) → `ESC[>0;0;0c`
- DSR (`ESC[6n`) → `ESC[1;1R`
- XTVERSION (`ESC[>q`) → `ESC P>|claude-print ESC \`
- Window size (`ESC[18t`) → `ESC[8;50;220t`
- Without these, Ink hangs indefinitely at startup
### 3. Startup Sequencer
- Phase 1: accumulate startup bytes; after 0.8s idle gap (or 45s timeout), send CR to dismiss any trust dialog
- Phase 2: after 2.0s gap post-CR, send prompt via bracketed paste (`ESC[200~...ESC[201~` + CR)
- Detects `trust` + `folder` in PTY output and sends CR immediately
### 4. Stop Hook (completion signal)
- Before spawning: write per-run settings overlay to `~/.config/claude-print/<pid>/settings.json`
```json
{"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "/path/to/hook.sh"}]}]}}
```
- Invoke claude with `--settings ~/.config/claude-print/<pid>/settings.json`
- Hook script reads stdin JSON, writes `{session_id, transcript_path, timestamp}` to named FIFO
- Parent polls FIFO; on receipt, breaks from PTY read loop
- Cleanup: remove settings overlay + FIFO on exit (defer)
- **This replaces the fragile idle-timeout approach in the NEEDLE plugin**
### 5. Transcript Reader
- On Stop hook receipt, extract `transcript_path` from hook payload
- Read JSONL, find last `type: "assistant"` event, extract `content[].text`
- Aggregate `usage` blocks across all assistant events for real token counts
- Retry loop (40 × 50ms) for Stop→JSONL flush race
- Fallback: extract `last_assistant_message` from Stop payload directly
### 6. Emitter
- `--output-format text` (default): print response text to stdout
- `--output-format json`: emit single JSON object with `result`, `session_id`, `usage`, `duration_ms`, `is_error`
- `--output-format stream-json`: tail transcript JSONL and emit lines as they appear (per-message streaming)
- All formats match `claude -p` wire format
## Data Models
### Stop Hook Payload (from Claude Code)
```json
{
"session_id": "abc123",
"transcript_path": "/home/coding/.claude/projects/.../abc123.jsonl",
"last_assistant_message": "...",
"hook_event_name": "Stop"
}
```
### Emitted JSON (--output-format json)
```json
{
"type": "result",
"subtype": "success",
"is_error": false,
"result": "assistant response text",
"session_id": "abc123",
"num_turns": 1,
"duration_ms": 4200,
"cost_usd": 0,
"usage": {
"input_tokens": 1240,
"output_tokens": 380,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 900
}
}
```
### NEEDLE Agent Config (`claude-print.yaml`)
```yaml
name: claude-print
description: Claude Code interactive mode — subscription billing (cc_entrypoint=cli)
agent_cli: claude-print
input_method:
method: stdin
invoke_template: "cd {workspace} && claude-print --model {model} --max-turns 30 --dangerously-skip-permissions"
timeout_secs: 3600
provider: anthropic
model: claude-sonnet-4-6
output_transform: needle-transform-claude
```
## Implementation Phases
- [ ] Phase 1: Core PTY wrapper — spawner, terminal probe responder, startup sequencer, idle-timeout completion (port from NEEDLE plugin, working baseline)
- [ ] Phase 2: Stop hook completion — per-run settings overlay, named FIFO, hook script, poll loop (replaces idle timeout)
- [ ] Phase 3: Transcript reader — JSONL parse, token extraction, retry loop, fallback to Stop payload
- [ ] Phase 4: Emitter — text/json/stream-json output formats, wire-compat with `claude -p`
- [ ] Phase 5: NEEDLE integration — `claude-print.yaml` agent config, `install.sh`, test with NEEDLE worker
- [ ] Phase 6: Tests and CI — unit tests for transcript parsing, mock PTY scenarios, Argo Workflows CI
## Open Questions
- **Language**: Python (port from NEEDLE plugin, fast iteration) or Rust (native NEEDLE integration)? Python has `pyte` and `pty` ready; Rust would need a PTY crate.
- **`--settings` overlay vs project-level hooks**: Per-run `--settings` file (smithersai approach) avoids mutating `~/.claude/settings.json` and is self-contained. Project-level `.claude/settings.json` per workspace is an alternative but affects all sessions in that workspace.
- **pyte for response extraction**: Still needed for fallback when Stop payload `last_assistant_message` is absent (older Claude Code versions). Keep or drop?
- **Multiline prompts**: NEEDLE sends prompt via stdin pipe. Bracketed paste handles embedded newlines correctly; verify with very long prompts (>32KB).
- **Rate limit 429 handling**: Claude emits an error event in the transcript; `is_error: true` and exit 1. No explicit retry — callers handle retry.
- **Dedicated GitHub repo vs NEEDLE plugin**: Standalone repo (`jedarden/claude-print`) allows independent versioning, pre-built releases, and use outside NEEDLE. NEEDLE plugin stays as thin wrapper pointing at the binary.