feat: interactive subscription transport (Bun ConPTY)#10
Conversation
Port claudeSession to Bun.spawn({terminal}) so the plugin can drive interactive claude in-process (subscription path) without node-pty or a node sidecar. Multi-turn ClaudeSession + askOnce, JSONL-tail capture, stop_reason completion. e2e green vs real claude (3-message chat, context retained, prompt-cache reuse). Not wired into doStream yet.
Gated by CLAUDE_CODE_INTERACTIVE_TRANSPORT (self-healing on Bun.Terminal). When on, doStream drives the interactive claude TUI over Bun native ConPTY + JSONL-tail instead of headless --print stream-json, keeping calls on the subscription path. ClaudeSession.tailTurn re-emits transcript records; claude-session-wrapper adapts it to the ActiveProcess contract and synthesizes a result line so the existing finish branch runs unchanged. Headless stays the default. Also fix an orphan tool-result for skipped internal tools on the non-partial branch (register toolCallsById only inside !skip, mirroring the streaming path). Verified e2e: multi-turn text + built-in tool + MCP via doStream, and a real opencode run.
tailTurn reported only the final assistant record's output_tokens, undercounting multi-record (tool) turns. Sum output across all records this turn; keep input/cache from the last record (full context); patch iterations[last] since toUsage prefers it. Verified: a tool turn reports 422 = transcript sum across 4 records; input = last-record full context.
Read the interactive flag from provider options (provider.claude-code.options.interactive / interactiveBypass) in addition to the env var, so the opencode GUI app - which does not inherit User-scope env vars - can enable it via config. Falls back to CLAUDE_CODE_INTERACTIVE_TRANSPORT.
There was a problem hiding this comment.
Code Review
This pull request introduces a persistent interactive Claude Code session driven over Bun's native PTY (ConPTY/openpty) and JSONL transcript tailing, enabling subscription-based interactive transport. Key feedback highlights a bug where checking globalThis.Bun?.Terminal always evaluates to false (silently disabling the feature), potential race conditions in ask() and tailTurn() that could discard final transcript lines upon process exit, and a performance concern regarding reading the entire JSONL file on every poll tick.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const useInteractive = | ||
| interactivePref && typeof (globalThis as any).Bun?.Terminal === "function" |
There was a problem hiding this comment.
Checking globalThis.Bun?.Terminal will always evaluate to false because Bun.Terminal is not a globally exposed constructor or function in Bun's public API (PTY is configured via Bun.spawn({ terminal: ... })). This silently disables the interactive transport feature entirely.
Using Bun.spawn to detect Bun's spawning capability is a more reliable check.
const useInteractive =
interactivePref && typeof (globalThis as any).Bun?.spawn === "function"There was a problem hiding this comment.
Not applying this one. Bun.Terminal is a real global as of Bun 1.3.14, which is the version opencode embeds and the one this transport targets. bun -e "console.log(typeof Bun.Terminal)" prints function, and the interactive branch does activate at runtime (the session logs spawned interactive claude session, the transcript is created, no headless --print). The check is deliberate feature-detection: Bun.spawn exists in every Bun version, including ones predating the native PTY (the terminal option also landed in 1.3.14), so probing Bun.spawn would pass the gate on a Bun too old to support Bun.spawn({ terminal }) and then fail at spawn instead of falling back to headless. Bun.Terminal is the narrower, correct signal and keeps the self-healing fallback intact.
| if (this.exited) throw new Error("claude exited mid-turn") | ||
| const lines = this.readRawLines() | ||
| const lastComplete = lines.length - 1 // exclusive bound; trailing/partial line skipped | ||
| if (lastComplete <= this.cursor) continue |
There was a problem hiding this comment.
There is a potential race condition on process exit. If this.exited becomes true, the loop immediately throws "claude exited mid-turn" before reading the final lines of the transcript. This can cause valid final responses written right before exit to be lost.
Checking for new lines first and only throwing if no new lines are available solves this issue.
const lines = this.readRawLines()
const lastComplete = lines.length - 1 // exclusive bound; trailing/partial line skipped
if (lastComplete <= this.cursor) {
if (this.exited) throw new Error("claude exited mid-turn")
continue
}There was a problem hiding this comment.
Fixed in e08bd3c. The loop reads the transcript first and only throws claude exited mid-turn when there are no new lines left to drain. The real-world window is small: the interactive claude is long-lived and a turn completes on a terminal stop_reason while the process is still alive, so exited mid-turn is the crash path rather than normal completion. Took it as cheap hardening anyway.
| if (this.exited) break | ||
| const lines = this.readRawLines() | ||
| const lastComplete = lines.length - 1 | ||
| if (lastComplete <= this.cursor) continue |
There was a problem hiding this comment.
Similar to the race condition in ask(), checking this.exited before reading the transcript can cause the loop to break prematurely, skipping the final lines written to the JSONL file right before exit.
Reading the lines first and only breaking if no new lines are available ensures all transcript records are processed.
const lines = this.readRawLines()
const lastComplete = lines.length - 1
if (lastComplete <= this.cursor) {
if (this.exited) break
continue
}There was a problem hiding this comment.
Fixed in e08bd3c, same reorder. The loop now emits any pending lines before honoring exited (it breaks only once there is nothing new), so a terminal record flushed in the same tick as exit still reaches onLine and sets stopReason. Same caveat as ask(): low probability on a persistent TUI, but the fix is free.
| private readRawLines(): string[] { | ||
| try { | ||
| return fs.readFileSync(this.jsonlPath, "utf8").split("\n") | ||
| } catch { | ||
| return [] | ||
| } | ||
| } |
There was a problem hiding this comment.
Reading and splitting the entire JSONL transcript file on every single poll tick (default 250ms) scales quadratically with the transcript size. As the conversation grows, this will lead to significant CPU and I/O overhead.
Consider tracking the file size/offset (e.g., using fs.statSync or keeping a byte offset) and only reading the newly appended bytes when the file actually changes.
There was a problem hiding this comment.
Valid, leaving it for a follow-up rather than this PR. Only the new lines are JSON-parsed (cursor-bounded); the repeated cost is the whole-file read + split, which the OS page cache absorbs at the typical transcript size and only bites on long multi-MB sessions. Will switch to a byte-offset tail (track size via statSync, read only the appended bytes) when that is worth the partial-line bookkeeping.
ask() and tailTurn() checked this.exited before reading the JSONL transcript, so a final assistant record flushed in the same poll tick as process exit was dropped (turn errored, or returned a null stop_reason). Read the transcript first; react to exit only when no new lines remain.
|
Pushed e08bd3c. Verified live against interactive
All green. For the record: #1 is a false positive ( |
A large/multi-line bracketed paste collapses into a "[Pasted text]" placeholder in the Claude TUI. The old code pressed Enter after a fixed 200ms delay, but for a big paste ConPTY is still draining bytes then, so the \r lands inside the still-open paste and is silently dropped. The turn never submits and the call hangs until turnTimeoutMs (120s). Replace the blind delay+Enter in both ask() and tailTurn() with submitTurn(): press Enter, poll the JSONL transcript for growth past the cursor (turn accepted on first record write), and resend Enter until accepted, up to submitMaxRetries. Condition-based instead of timing-based, so robust to paste size; polling growth also avoids a stray Enter once the turn is in flight. Ports the fix already applied to the node-pty session. typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Shipped in v0.9.0 and published to npm as It stays opt-in ( |
Drives the interactive claude TUI over Bun native ConPTY + JSONL-tail instead of headless
claude --printstream-json, so model calls stay on the subscription path (not the metered Agent SDK pool after 06-15). Opt-in viaprovider.claude-code.options.interactive(envCLAUDE_CODE_INTERACTIVE_TRANSPORTfallback); headless stays the default, so it is a no-op unless enabled. Refs #9.