Skip to content

feat: interactive subscription transport (Bun ConPTY)#10

Merged
khalilgharbaoui merged 6 commits into
khalilgharbaoui:masterfrom
Aptul9:feat/bun-native-transport
Jun 10, 2026
Merged

feat: interactive subscription transport (Bun ConPTY)#10
khalilgharbaoui merged 6 commits into
khalilgharbaoui:masterfrom
Aptul9:feat/bun-native-transport

Conversation

@Aptul9

@Aptul9 Aptul9 commented Jun 8, 2026

Copy link
Copy Markdown

Drives the interactive claude TUI over Bun native ConPTY + JSONL-tail instead of headless claude --print stream-json, so model calls stay on the subscription path (not the metered Agent SDK pool after 06-15). Opt-in via provider.claude-code.options.interactive (env CLAUDE_CODE_INTERACTIVE_TRANSPORT fallback); headless stays the default, so it is a no-op unless enabled. Refs #9.

Aptul9 added 4 commits June 8, 2026 09:05
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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1671 to +1672
const useInteractive =
interactivePref && typeof (globalThis as any).Bun?.Terminal === "function"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/claude-session-bun.ts Outdated
Comment on lines +238 to +241
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
      }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/claude-session-bun.ts Outdated
Comment on lines +324 to +327
if (this.exited) break
const lines = this.readRawLines()
const lastComplete = lines.length - 1
if (lastComplete <= this.cursor) continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
      }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/claude-session-bun.ts
Comment on lines +196 to +202
private readRawLines(): string[] {
try {
return fs.readFileSync(this.jsonlPath, "utf8").split("\n")
} catch {
return []
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@Aptul9

Aptul9 commented Jun 8, 2026

Copy link
Copy Markdown
Author

Pushed e08bd3c. Verified live against interactive claude over Bun ConPTY, no regression from the reorder:

  • ask() multi-turn: text reply, terminal stop_reason end_turn, usage present, context retained across turns.
  • tailTurn() (the raw-line path doStream consumes): JSONL lines emitted, assistant record + terminal stop_reason, output tokens summed.

All green. For the record: #1 is a false positive (typeof Bun.Terminal === "function" on Bun 1.3.14, which is the version opencode embeds), and the perf nit is deferred to a follow-up.

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>
khalilgharbaoui added a commit that referenced this pull request Jun 10, 2026
@khalilgharbaoui khalilgharbaoui merged commit 197928f into khalilgharbaoui:master Jun 10, 2026
@khalilgharbaoui

Copy link
Copy Markdown
Owner

Shipped in v0.9.0 and published to npm as @khalilgharbaoui/opencode-claude-code-plugin@0.9.0. Thanks for the interactive transport, @Aptul9 .

It stays opt-in (interactive: true / CLAUDE_CODE_INTERACTIVE_TRANSPORT=1) with headless --print as the default. One adjustment on merge: interactive mode omits opencode's forwarded system prompt by default, since that payload can trip the third-party-app usage gate on constrained subscription accounts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants