Skip to content

feat: interactive PTY mode for Claude Code#882

Open
junmo-kim wants to merge 11 commits into
tiann:mainfrom
junmo-kim:feat/interactive-pty
Open

feat: interactive PTY mode for Claude Code#882
junmo-kim wants to merge 11 commits into
tiann:mainfrom
junmo-kim:feat/interactive-pty

Conversation

@junmo-kim

Copy link
Copy Markdown
Contributor

What

Adds an opt-in interactive PTY mode for Claude Code. Instead of spawning the agent headlessly over the SDK, the runner launches claude inside a real PTY and the web UI shows both a live terminal view and the usual structured chat — driven from the same session.

Enable it per-session with the new "PTY mode" checkbox in the new-session form (Claude only; off by default).

Why

A real PTY runs Claude Code through its interactive entrypoint, so the agent behaves exactly as it does in a local terminal (TUI, slash commands, model switching) while remote users keep the structured chat they already rely on. The live terminal makes long-running or interactive turns observable from the phone.

There's also a usage-accounting reason to prefer the interactive entrypoint. Anthropic now meters interactive Claude Code separately from programmatic Agent-SDK use: subscription usage limits "stay reserved for interactive use of Claude Code," whereas "Claude Agent SDK and claude -p usage no longer counts toward your Claude plan's usage limits" and instead draws on a separate Agent-SDK monthly credit or pay-as-you-go API rates (policy). HAPI is a remote control over a Claude Code session a person is actively driving, not a headless automation product — in this mode it is essentially no different from an SSH client: a transport that relays an interactive terminal to and from the user. Launching the genuine interactive entrypoint (PTY) instead of the SDK simply keeps that usage categorized as the interactive session it really is.

How it works

  • The runner launches the real claude interactive entrypoint in a PTY and drives it from chat — the first message, in-place /model and /effort switches, and --resume. First-run folder-trust is pre-accepted in a disposable CLAUDE_CONFIG_DIR, so the user's own config is never touched.
  • The structured chat is reconstructed by tailing Claude's JSONL transcript, so remote users get the same message/tool view as a non-PTY session; the live terminal is streamed alongside it (with scrollback replayed on reconnect).
  • Permissions and questions stay in the chat. The SDK path routes tool approvals through canUseTool, which a PTY agent doesn't have — so a PreToolUse hook forwards each tool call to the runner, which surfaces it in the existing web approval modal and returns the decision to claude. Read-only tools auto-allow; everything else asks the web. The decision is always allow/deny (never ask, which would drop back to the TUI) and fails closed on errors, so the flow never silently auto-runs or hangs. AskUserQuestion is answered in the chat the same way.

Screenshots

Live agent terminal (Claude Code TUI) Structured chat in a PTY session
agent terminal structured chat
PTY mode opt-in In-session model / permission change
new-session option model change
Tool approval in chat (PTY) AskUserQuestion answered in chat
permission modal question card

Testing

  • cli / web (vitest), hub / shared (bun test): full suites green; TypeScript strict clean across all packages.
  • New unit coverage for the PTY driver (spawn/ready/submit-retry/trust/idle), the resume verify-resubmit, terminal room routing + scrollback replay, the disposable config dir, and the PreToolUse approval bridge (auto-allow / web round-trip / deny / AskUserQuestion answers / fail-closed).
  • Manually verified end-to-end on desktop and mobile.

Scope

Claude-only and opt-in. The driver is written generically so other PTY-based flavors can follow without reworking it. No schema migrations, and no change to existing (non-PTY) sessions. A second flavor (Antigravity CLI) will follow on the same driver in a separate PR.

…util

Move the AskUserQuestion / request_user_input answer-to-input builders (and the
question tool-name predicates) out of the SDK permission handler into a standalone
util. No behavior change — the SDK handler imports them — so the PTY permission
bridge can reuse them without pulling in the SDK handler's dependencies.

@github-actions github-actions 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.

Findings

  • [Major] Agent-terminal subscribe bypasses session namespace checks — agent-terminal:subscribe joins agent-session:<sessionId> and replays getAgentTerminalReplay(sessionId) before validating that the requested session belongs to the authenticated namespace. The existing terminal:create path does this check, and the CLI output path broadcasts future PTY bytes to the room name alone, so a client with any valid token can subscribe to a guessed/known session id in another namespace and receive buffered/future agent terminal output. Evidence: hub/src/socket/handlers/terminal.ts:249.
  • [Minor] Hidden agent terminals still subscribe and enable PTY streaming — AgentTerminalView connects on mount even when visible is false, and the hook subscribes immediately on connect. Because SessionChat mounts this component hidden for every active PTY session, merely opening the chat flips agentTerminalActive on in the CLI and streams the high-frequency raw TUI path even when the user never opens the terminal. Evidence: web/src/components/AgentTerminal/AgentTerminalView.tsx:111.

Summary
Review mode: initial. Two issues found in the latest diff: missing namespace authorization on agent-terminal subscription and a hidden-view subscription that defeats the intended viewer-gated PTY streaming.

Testing
Not run (automation).

HAPI Bot

Comment thread hub/src/socket/handlers/terminal.ts
Comment thread web/src/components/AgentTerminal/AgentTerminalView.tsx

@github-actions github-actions 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.

Findings

  • [Major] Deterministic terminal IDs make multiple viewers steal the same shell — web/src/routes/sessions/terminal.tsx:191 now uses term-${sessionId} for every browser/tab/device viewing a session. The hub registry treats a reused terminal ID from another socket as a stale reconnect and removes the first socket's entry, then the CLI TerminalManager.create() sees the same ID and reuses the existing PTY instead of spawning an independent shell. Result: opening the terminal for the same session from a second device reroutes output to the second viewer and leaves the first viewer unable to write/resize reliably. Evidence: web/src/routes/sessions/terminal.tsx:191, related registry behavior hub/src/socket/terminalRegistry.ts:27.

Summary
Review mode: follow-up after new commits. Prior namespace/subscription concerns appear addressed, but the full latest diff still introduces a regression in the existing remote terminal route: terminal IDs are no longer per viewer.

Testing
Not run (automation). Missing coverage for two simultaneous /sessions/:id/terminal sockets using the same session.

HAPI Bot

Comment thread web/src/routes/sessions/terminal.tsx Outdated

@github-actions github-actions 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.

Findings

  • [Major] First-message verifier can re-submit before the PTY driver has submitted — cli/src/claude/claudePtyLauncher.ts:421 starts ensureFirstMessageDelivered() inside nextMessage(), before control returns to runAgentPty and before runAgentPty runs its own waitForInputReady() + submitMessage() path. On a slow --resume replay, that verifier can hit its 5s timeout while the normal submit is still waiting for the prompt, write the prompt directly through ptyControls, and then the driver sends the same text again once ready. That can duplicate the first user message or inject it into the replaying TUI.
    Suggested fix:
    // RunAgentPtyOpts
    onMessageSubmitted?: (message: string) => void | Promise<void>
    
    // after the normal driver submit path
    await submitMessage(next.message)
    await opts.onMessageSubmitted?.(next.message)
    Then wire claudePtyLauncher to call ensureFirstMessageDelivered() from that post-submit hook for only the first submitted message.

Summary

  • Review mode: follow-up after new commits. The previous terminal-id finding is addressed. The latest full diff still has a PTY resume-path regression around first-message verification timing.

Testing

  • Not run (automation). Suggested: add a PTY launcher/driver test where nextMessage() returns, runAgentPty remains in waitForInputReady() for more than 5s, and the verifier must not write keys until after the driver has submitted.

HAPI Bot

Comment thread cli/src/claude/claudePtyLauncher.ts Outdated

@github-actions github-actions 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.

Findings

  • [Major] Multiline prompts are submitted as multiple PTY commands — PTY mode writes the queued web composer text directly to the terminal and then appends Enter. Web messages can be multiline already (normal composer text, batched queued messages joined with \n, and attachment prompts formatted as @path\n\ntext), so every embedded newline is interpreted by Claude's interactive TUI as Enter before the final submit. The first line runs by itself and the remaining lines can become separate slash commands/prompts or get typed while Claude is already responding. Evidence: cli/src/agent/runAgentPty.ts:323 submits next.message through the raw PTY path; cli/src/claude/runClaude.ts formats attachments into multiline text before queueing.
    Suggested fix:
    const submitMessage = async (message: string): Promise<void> => {
        const payload = message.includes('\n')
            ? `\x1b[200~${message}\x1b[201~`
            : message
    
        let echoed = false
        for (let attempt = 0; attempt < 3 && !echoed; attempt++) {
            const before = lastOutputAt
            manager.write(payload)
            // existing echo wait...
        }
        await sleep(150)
        manager.write('\r')
    }
    Add a PTY driver test that sends line 1\nline 2 and asserts the embedded newline is bracketed-paste escaped, with only the final \r submitting.

Summary

  • Review mode: follow-up after new commits. The previous first-message verifier race is addressed, but the latest full diff still has a PTY input regression for multiline web prompts/attachments.

Testing

  • Not run (automation). Suggested: add the multiline PTY submit test above and cover an attachment-formatted prompt path.

HAPI Bot

Comment thread cli/src/agent/runAgentPty.ts

@github-actions github-actions 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.

Findings

  • [Major] First-message repair still splits multiline prompts — the primary PTY submit path now wraps multiline input in bracketed paste, but the resume/drop repair path re-types the first prompt with raw sendKeys(trimmed). If the first submitted message is multiline (composer newline, batched queue, or attachment prompt) and the initial submit is swallowed by a slow --resume, the repair turns embedded newlines into Enter keystrokes, so Claude receives separate prompts/commands instead of the original message. Evidence: cli/src/claude/claudePtyLauncher.ts:256.
    Suggested fix:
    const repairPayload = text.includes('\n')
        ? `\x1b[200~${text}\x1b[201~`
        : text
    this.ptyControls.sendKeys(repairPayload)
    await this.sleep(200)
    this.ptyControls.sendKeys('\r')

Summary

  • Review mode: follow-up after new commits. The previous primary submit regression is fixed, but the first-message delivery repair path still needs the same multiline handling.

Testing

  • Not run (automation). Suggested: add a launcher/repair test where the first submitted message is multiline and transcript confirmation stays false, then assert the retry writes bracketed paste plus one final \r.

HAPI Bot

Comment thread cli/src/claude/claudePtyLauncher.ts Outdated

@github-actions github-actions 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.

Findings

  • [Major] Local handoff now starts PTY for every Claude session — runLocalRemoteLoop now chooses pty whenever a runPty launcher is registered, and Claude always registers one. That changes the existing local hapi flow: pressing space to switch from local mode no longer enters the normal SDK remote launcher unless the session explicitly opted into PTY. It also bypasses the PR's stated opt-in behavior for PTY mode. Evidence: cli/src/agent/loopBase.ts:50.
    Suggested fix:
    const remoteMode: SessionMode = opts.startingMode === 'pty' ? 'pty' : 'remote'
    // ...
    mode = remoteMode
    opts.session.onModeChange(mode === 'pty' ? 'remote' : mode)

Summary

  • Review mode: follow-up after new commits. The previous multiline repair finding is fixed. One remaining regression: default Claude local-to-remote handoff now routes through PTY because PTY availability is treated as opt-in.

Testing

  • Not run (automation). Suggested: add a runLocalRemoteLoop test where startingMode: 'local' and runPty is provided; after local returns switch, assert runRemote is called. Add a second test for startingMode: 'pty' to keep the opt-in PTY path covered.

HAPI Bot

Comment thread cli/src/agent/loopBase.ts Outdated

@github-actions github-actions 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.

Findings

  • [Major] PTY spawn failures are swallowed — runAgentPty only logs onError and returns when manager.isRunning is false, so ClaudePtyLauncher sees a successful await claudePty(...), consumes flags, and loops back into another launch instead of surfacing PTY process error or ending the session. This affects real failures such as claude not being installed or Bun failing to attach a terminal. Evidence: cli/src/agent/runAgentPty.ts:271.
    Suggested fix:
    let spawnError: Error | null = null
    manager.spawn({
        // ...
        onError: (error) => {
            spawnError = error
            logger.debug(`${debugPrefix} PTY error: ${error.message}`, error)
        },
    })
    
    if (!manager.isRunning) {
        throw spawnError ?? new Error(`Failed to spawn ${opts.command} PTY`)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. The previous handoff regression is fixed. One remaining issue: PTY launch failures currently look like successful returns to the caller, which can leave runner-spawned PTY sessions retrying without a useful user-visible failure.

Testing

  • Not run (automation). Suggested: add a runAgentPty test where the mocked AgentPtyManager.spawn calls onError and leaves isRunning=false, asserting runAgentPty rejects; add a ClaudePtyLauncher test asserting the rejection emits PTY process error instead of looping.

HAPI Bot

Comment thread cli/src/agent/runAgentPty.ts

@github-actions github-actions 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.

Findings

  • [Major] PTY startup failures are classified as recoverable after spawn — runAgentPty calls onReady() before waitForInputReady(), but ClaudePtyLauncher treats that callback as “reached a ready prompt” and resets the immediate-failure counter. If claude spawns and exits before rendering a prompt (bad config, invalid args, auth failure, etc.), reachedReady is already true, so the new give-up/backoff path is bypassed and the launcher can keep respawning forever. Evidence: cli/src/agent/runAgentPty.ts:299.
    Suggested fix:
    await waitForInputReady()
    if (!manager.isRunning || signal?.aborted) {
        throw new Error(`${opts.command} PTY exited before becoming ready`)
    }
    opts.onReady()

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. The previous spawn-error finding is addressed, but the follow-up retry classifier still marks a PTY as ready immediately after spawn instead of after the prompt is actually usable.

Testing

  • Not run (automation). Suggested: add a runAgentPty test where the mocked PTY exits before any ready output and assert onReady is not called; add a ClaudePtyLauncher test that repeated pre-ready exits reach give-up.

HAPI Bot

Comment thread cli/src/agent/runAgentPty.ts Outdated

@github-actions github-actions 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.

Findings

  • [Major] PTY sessions lose their terminal affordance after a local handoff. The new metadata field is documented and consumed as the original launch mode (metadata.startingMode === 'pty' gates the agent-terminal button), but setControlledByUser() rewrites it every time the collaboration mode changes. In the PTY loop, leaving PTY for local calls setControlledByUser(..., 'local'), and the next handoff back to PTY reports external mode 'remote', so this line persists startingMode: 'remote' for a still-PTY-backed session. After the SSE update, SessionChat no longer renders the terminal toggle even though the PTY is running. Evidence: cli/src/agent/runnerLifecycle.ts:149, consumed at web/src/components/SessionChat.tsx:315.
    Suggested fix:
    export function setControlledByUser(
        session: ApiSessionClient,
        mode: 'local' | 'remote',
        launchMode?: 'local' | 'remote' | 'pty'
    ): void {
        session.updateAgentState((currentState) => ({
            ...currentState,
            controlledByUser: mode === 'local',
            startingMode: launchMode ?? currentState.startingMode ?? mode
        }))
        session.updateMetadata((metadata) => ({
            ...metadata,
            startingMode: launchMode ?? metadata.startingMode ?? mode
        }))
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. The previous startup-failure finding is addressed. I found one remaining regression in how PTY launch identity is persisted across local/remote handoff.

Testing

  • Not run (automation). Suggested: add a loop/session lifecycle test for a pty -> local -> pty switch and assert metadata/agentState startingMode remains pty.

HAPI Bot

Comment thread cli/src/agent/runnerLifecycle.ts Outdated

@github-actions github-actions 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.

Findings

  • No issues found with confidence >= 80% in the current diff.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. I re-reviewed the full current diff, including the PTY launch path, metadata/start mode persistence, terminal socket authorization/replay, permission hook bridge, session scanning, spawn/reopen flow, and the follow-up changes since the prior bot review. The prior startingMode handoff regression appears addressed by preserving pty across local/remote transitions and adding coverage for that lifecycle.

Testing

  • Not run (automation). Residual risk: PTY behavior depends on Claude's live TUI/hooks, so the main uncovered risk is end-to-end behavior with a real Claude CLI across spawn, remote chat input, terminal toggle, local handoff, and reopen.

HAPI Bot

… approvals

Launch claude inside a PTY and drive it from the web: chat input, in-place
/model and /effort changes (auto-confirming claude's "Switch model?" dialog),
and --resume.

Tool approvals are bridged to the web instead of the SDK's canUseTool (which a
PTY agent doesn't have): a PreToolUse hook forwards each tool call to the runner,
which surfaces it in the existing web approval modal (reusing state.requests +
the permission RPC — no web changes) and returns allow/deny to claude.

- generateHookSettings registers the PreToolUse hook (PTY only) with a generous
  timeout so the blocking hook survives a slow phone approval.
- sessionHookForwarder branches on the stdin hook_event_name: PreToolUse posts to
  a new endpoint and echoes claude's hookSpecificOutput decision on stdout;
  SessionStart keeps its fire-and-forget behavior.
- startHookServer gains a /hook/pre-tool-use endpoint that awaits the decision.
- PtyPermissionHandler reuses BasePermissionHandler. Read-only tools auto-allow;
  bypassPermissions allows all; everything else asks the web. "Allow for session"
  is honored, including Bash's command-qualified form (Bash(<cmd>) / Bash(<prefix>:*)).
  Question tools (AskUserQuestion / request_user_input) are routed to the web too:
  the picked answers are injected back via the tool's updatedInput so claude echoes
  them instead of prompting in its TUI. Decisions are always allow/deny (never ask,
  which would fall back to the TUI prompt) and fail closed on errors.

A default-mode PTY session therefore prompts for permission — and asks its
questions — in the chat like the SDK path; the explicit YOLO toggle still
opts into --yolo.
Hoist the launch/ready/backoff/give-up respawn loop out of the Claude PTY
launcher into a RemoteLauncherBase.runRespawnLoop template method, so a second
PTY flavor can reuse it instead of duplicating the loop. No behavior change.
On a turn-stop RPC, write the Esc interrupt key into the live PTY (the claude
TUI's own interrupt) instead of killing the process, when PTY controls are
active. Falls back to aborting the controller when they are not.
The standalone terminal's quick-input keys (Esc/arrows/Ctrl/Alt) and their
sticky-modifier dispatch were inline in terminal.tsx. Extract them to a shared
QuickKeys module — a useQuickKeyInput hook (modifier state + dispatcher) and a
QuickKeyRows presentational component — so other terminal views can reuse them
without duplication. Behavior-preserving: the raw onData path stays ungated and
shares the modifier state with the buttons, exactly as before.
The agent-terminal view was output-only, so a remote viewer could not navigate
TUI screens the structured chat composer cannot express — escape a /usage
screen, answer a /model dialog, or send Ctrl-C. Add an agent-terminal:input
path (web emit -> hub relay -> CLI -> agent PTY) so the xterm onData and a shared
QuickKey bar drive the live TUI. The CLI now stores the launcher's sendKeys
control, which setAgentTerminalControls previously dropped. The relay reuses the
same authorization guard as resize (authorized viewer of an active session in
the namespace). The chat composer remains the primary way to send messages.
@junmo-kim junmo-kim force-pushed the feat/interactive-pty branch from c1995cf to f2865d1 Compare June 14, 2026 04:43

@github-actions github-actions 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.

Findings

  • No issues found with confidence >= 80% in the current diff.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits. I re-reviewed the full current diff and used the prior HAPI Bot review plus the compare diff only as follow-up context. I did not find any correctness, security, regression, data-loss, performance, or maintainability issue introduced by the added/modified lines with enough confidence to report. Residual risk: PTY behavior still depends on live Claude CLI/TUI hooks and reconnect behavior that is hard to fully prove from static review.

Testing

  • Not run (automation): bun is not installed in this runner (/bin/bash: bun: command not found), so bun typecheck and bun run test -- --runInBand could not execute.

HAPI Bot

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.

1 participant