feat: interactive PTY mode for Claude Code#882
Conversation
…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.
There was a problem hiding this comment.
Findings
- [Major] Agent-terminal subscribe bypasses session namespace checks —
agent-terminal:subscribejoinsagent-session:<sessionId>and replaysgetAgentTerminalReplay(sessionId)before validating that the requested session belongs to the authenticated namespace. The existingterminal:createpath 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 —
AgentTerminalViewconnects on mount even whenvisibleis false, and the hook subscribes immediately on connect. BecauseSessionChatmounts this component hidden for every active PTY session, merely opening the chat flipsagentTerminalActiveon 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
There was a problem hiding this comment.
Findings
- [Major] Deterministic terminal IDs make multiple viewers steal the same shell —
web/src/routes/sessions/terminal.tsx:191now usesterm-${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 CLITerminalManager.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 behaviorhub/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
There was a problem hiding this comment.
Findings
- [Major] First-message verifier can re-submit before the PTY driver has submitted —
cli/src/claude/claudePtyLauncher.ts:421startsensureFirstMessageDelivered()insidenextMessage(), before control returns torunAgentPtyand beforerunAgentPtyruns its ownwaitForInputReady()+submitMessage()path. On a slow--resumereplay, that verifier can hit its 5s timeout while the normal submit is still waiting for the prompt, write the prompt directly throughptyControls, 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:Then wire// RunAgentPtyOpts onMessageSubmitted?: (message: string) => void | Promise<void> // after the normal driver submit path await submitMessage(next.message) await opts.onMessageSubmitted?.(next.message)
claudePtyLauncherto callensureFirstMessageDelivered()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,runAgentPtyremains inwaitForInputReady()for more than 5s, and the verifier must not write keys until after the driver has submitted.
HAPI Bot
There was a problem hiding this comment.
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:323submitsnext.messagethrough the raw PTY path;cli/src/claude/runClaude.tsformats attachments into multiline text before queueing.
Suggested fix:Add a PTY driver test that sendsconst 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') }
line 1\nline 2and asserts the embedded newline is bracketed-paste escaped, with only the final\rsubmitting.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Findings
- [Major] Local handoff now starts PTY for every Claude session —
runLocalRemoteLoopnow choosesptywhenever arunPtylauncher is registered, and Claude always registers one. That changes the existing localhapiflow: 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
runLocalRemoteLooptest wherestartingMode: 'local'andrunPtyis provided; after local returnsswitch, assertrunRemoteis called. Add a second test forstartingMode: 'pty'to keep the opt-in PTY path covered.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] PTY spawn failures are swallowed —
runAgentPtyonly logsonErrorand returns whenmanager.isRunningis false, soClaudePtyLaunchersees a successfulawait claudePty(...), consumes flags, and loops back into another launch instead of surfacingPTY process erroror ending the session. This affects real failures such asclaudenot 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
runAgentPtytest where the mockedAgentPtyManager.spawncallsonErrorand leavesisRunning=false, assertingrunAgentPtyrejects; add aClaudePtyLaunchertest asserting the rejection emitsPTY process errorinstead of looping.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] PTY startup failures are classified as recoverable after spawn —
runAgentPtycallsonReady()beforewaitForInputReady(), butClaudePtyLaunchertreats that callback as “reached a ready prompt” and resets the immediate-failure counter. Ifclaudespawns and exits before rendering a prompt (bad config, invalid args, auth failure, etc.),reachedReadyis 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
runAgentPtytest where the mocked PTY exits before any ready output and assertonReadyis not called; add aClaudePtyLaunchertest that repeated pre-ready exits reachgive-up.
HAPI Bot
There was a problem hiding this comment.
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), butsetControlledByUser()rewrites it every time the collaboration mode changes. In the PTY loop, leaving PTY for local callssetControlledByUser(..., 'local'), and the next handoff back to PTY reports external mode'remote', so this line persistsstartingMode: 'remote'for a still-PTY-backed session. After the SSE update,SessionChatno longer renders the terminal toggle even though the PTY is running. Evidence:cli/src/agent/runnerLifecycle.ts:149, consumed atweb/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 -> ptyswitch and assert metadata/agentStatestartingModeremainspty.
HAPI Bot
There was a problem hiding this comment.
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
startingModehandoff regression appears addressed by preservingptyacross 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.
c1995cf to
f2865d1
Compare
There was a problem hiding this comment.
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):
bunis not installed in this runner (/bin/bash: bun: command not found), sobun typecheckandbun run test -- --runInBandcould not execute.
HAPI Bot
What
Adds an opt-in interactive PTY mode for Claude Code. Instead of spawning the agent headlessly over the SDK, the runner launches
claudeinside 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 -pusage 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
claudeinteractive entrypoint in a PTY and drives it from chat — the first message, in-place/modeland/effortswitches, and--resume. First-run folder-trust is pre-accepted in a disposableCLAUDE_CONFIG_DIR, so the user's own config is never touched.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 (neverask, which would drop back to the TUI) and fails closed on errors, so the flow never silently auto-runs or hangs.AskUserQuestionis answered in the chat the same way.Screenshots
Testing
cli/web(vitest),hub/shared(bun test): full suites green; TypeScript strict clean across all packages.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.