feat: interactive PTY mode for Claude Code#882
Conversation
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
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
There was a problem hiding this comment.
Findings
- [Major] Strip dangerous Claude skip flag for every PTY launch —
parseClaudeStartOptionsonly removes--dangerously-skip-permissionswhenpermissionMode === 'bypassPermissions', sohapi --hapi-starting-mode pty --permission-mode default --dangerously-skip-permissionsstill forwards the raw Claude flag while HAPI records/defaults todefault. In PTY mode this bypasses the PreToolUse hook installed inrunClaude.ts, so permission requests and AskUserQuestion no longer reach the web approval/question bridge. Evidence:cli/src/commands/claude.ts:82.
Suggested fix:if (options.startingMode === 'pty') { for (let i = unknownArgs.length - 1; i >= 0; i--) { if (unknownArgs[i] === '--dangerously-skip-permissions') { unknownArgs.splice(i, 1) } } }
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff, with prior bot review and compare diff as context only. One PTY permission-mode regression remains: an explicit permission mode plus Claude's dangerous skip flag can disable the hook path this PR relies on.
Testing
- Not run (automation):
bunis not installed in this runner, sobun typecheck/bun run testcould not execute.
HAPI Bot
5781777 to
d0d6cc6
Compare
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff, using the prior bot review and compare diff as context. The prior PTY permission-strip finding is addressed on this head; I did not find any remaining high-confidence issues introduced by the diff.
Testing
- Not run (automation):
bunis not installed in this runner, sobun typecheck/bun run testcould not execute.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] Abort restore can replay an unrelated historical prompt —
handleAbortRequestnow emitsabort-restoreeven when the PTY driver has not submitted a prompt in the current turn. The web consumer then walks backward and restores the last user message before that event, so an abort during idle/startup/no-submission paths can put an old prompt back into an empty composer and invite an accidental resend. Evidence:cli/src/claude/claudePtyLauncher.ts:332,web/src/components/SessionChat.tsx:290.
Suggested fix:Then have the web restoreprivate promptToRestoreOnAbort: string | null = null // in onMessageSubmitted this.promptToRestoreOnAbort = message // in handleAbortRequest, after queue.reset() const prompt = this.promptToRestoreOnAbort this.promptToRestoreOnAbort = null if (prompt) { this.session.client.sendSessionEvent({ type: 'abort-restore', text: prompt }) }
msg.content.textfrom the event instead of scanning historical user turns.
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff, using the prior bot review and compare diff as context. I found one high-confidence regression in the follow-up abort restore behavior.
Testing
- Not run (review automation): per PR-review security rules, I did not execute code from the PR.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Minor] Plain Bash session allow is recorded but never honored —
rememberBashPermission('Bash')storesBashinsessionAllowedTools, but the fast path handlestoolName === 'Bash'separately and only checks literal/prefix command sets. If the hub/API sends a plainallowTools: ['Bash'], later Bash calls still prompt instead of using the recorded session allow. Evidence:cli/src/claude/utils/ptyPermissionHandler.ts:98,cli/src/claude/utils/ptyPermissionHandler.ts:241.
Suggested fix:if (toolName === 'Bash') { if (this.sessionAllowedTools.has('Bash')) { return Promise.resolve({ permissionDecision: 'allow' }); } const command = (input as { command?: string } | null)?.command; if (command && this.isBashCommandAllowed(command)) { return Promise.resolve({ permissionDecision: 'allow' }); } }
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff, using the previous bot review and compare diff as context. The previous abort-restore finding appears addressed; I found one remaining PTY permission-state inconsistency.
Testing
- Not run (review automation): per PR-review security rules, I did not execute code from the PR.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] PTY launch drops runner-provided Claude OAuth token — runner-spawned Claude sessions pass per-session auth via
CLAUDE_CODE_OAUTH_TOKEN, but PTY mode now deletes everyCLAUDE_CODE_*variable before spawning the realclaudeprocess. For users relying on that token instead of a local Claude login, the HAPI child starts but the PTY child is unauthenticated, so the new opt-in mode fails or prompts for login instead of starting the session. Evidence:cli/src/claude/claudePty.ts:82with runner contextcli/src/runner/run.ts:398.
Suggested fix:function claudeInheritedEnvKeys(): string[] { return Object.keys(process.env).filter( (k) => k === 'CLAUDECODE' || (k.startsWith('CLAUDE_CODE_') && k !== 'CLAUDE_CODE_OAUTH_TOKEN') ) }
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff after the latest commit; the previous Bash session-allow finding is fixed. One remaining PTY auth regression found.
Testing
- Not run (review automation): per PR-review security rules, I did not execute code from the PR.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] PTY spawn returns before Claude is actually ready — the new PTY path tries to surface launch failures by waiting after
rpcGateway.spawnSession(), but it only waits forwaitForSessionActive()athub/src/sync/syncEngine.ts:764.activeis driven bysession-alive, and every CLI session emits that fromAgentSessionBaseconstruction before the PTY launcher has spawned Claude or reachedonReady()(cli/src/agent/sessionBase.ts:78). The PTY launcher currently sends only a chatreadyevent when the prompt is usable (cli/src/claude/claudePtyLauncher.ts:414), so failures like missingclaude, auth failure, or early PTY exit can still make/api/machines/:id/spawnreturn success and drop the user into an empty/failing terminal instead of reporting the spawn error.
Suggested fix:// cli/src/claude/claudePtyLauncher.ts onReady: () => { reachedReady = true this.session.client.emitSessionReady() this.session.client.sendSessionEvent({ type: 'ready' }) } // hub/src/sync/syncEngine.ts if (result.type === 'success' && startingMode === 'pty') { const readyResult = await this.waitForSessionReady(result.sessionId) if (readyResult !== 'ready') { return { type: 'error', message: readyResult === 'ended' ? 'Session ended before Claude PTY became ready' : 'Session spawned but failed to become ready' } } }
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff; the prior OAuth-token finding is fixed in the latest commit. One remaining readiness regression found in the PTY spawn flow.
Testing
- Not run (review automation).
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] PTY mode is never forwarded from the spawn route - the web client sends
startingMode: "pty"andSpawnSessionRequestSchemaaccepts it, but this positional call passesparsed.data.startingModeintoSyncEngine.spawnSession'sserviceTierparameter instead of itsstartingModeparameter (hub/src/web/routes/machines.ts:55). That meansSyncEngineseesstartingMode === undefined, skips the PTY readiness wait, andrpcGateway.spawnSession()sendsserviceTier: "pty"with nostartingMode; the runner then defaults to--hapi-starting-mode remote, so the new checkbox silently launches the SDK remote path instead of PTY.
Suggested fix:const result = await engine.spawnSession( machineId, parsed.data.directory, parsed.data.agent, parsed.data.model, parsed.data.modelReasoningEffort, parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName, undefined, parsed.data.effort, undefined, undefined, parsed.data.startingMode )
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff; the prior readiness issue is addressed, but the public new-session route still drops the new PTY mode before it reaches the runner.
Testing
- Not run (review automation). Add a route-level spawn test that posts
{ startingMode: "pty" }and assertsSyncEngine.spawnSessionreceives it in thestartingModeslot.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] Queued PTY prompts can be submitted before Claude is idle —
promptSeenis latched after the first prompt marker, so the laterwaitForInputReady()calls only require 500ms of output silence before writing the next queued message. During a normal long response, any quiet gap can satisfy that condition even though Claude has not rendered the next input prompt yet, so queued messages can be typed into a busy TUI and get dropped, appended to the wrong input, or interrupt the current turn. Evidencecli/src/agent/runAgentPty.ts:344.
Suggested fix:let inputReady = false const waitForInputReady = async (timeoutMs = 20000): Promise<void> => { const start = Date.now() while (Date.now() - start < timeoutMs) { if (signal?.aborted || !manager.isRunning) return const idle = Date.now() - lastOutputAt if (hasMarkers) { if (inputReady && idle >= idleReadyMs) return } else if (sawOutput && idle >= idleReadyMs) { return } await sleep(80) } } // when a prompt/idle marker is seen: inputReady = true // immediately before/after submitting a prompt: inputReady = false await submitMessage(next.message) setThinking(true)
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff. The previous
machines.tsforwarding issue is fixed; one PTY queue-ordering issue remains.
Testing
- Not run (review automation). Add a
runAgentPtytest with two queued messages where the first turn emits non-prompt output and then goes quiet; assert the second message is not written until a later prompt/idle marker arrives.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] PTY queue readiness timeout still submits into a busy turn —
waitForInputReady()falls out after 20s without telling the caller whether a fresh prompt/idle marker was seen, and the caller then submits the queued message anyway. A normal Claude turn can run longer than 20s while continuing to emit busy output, so the second queued prompt is typed into the active TUI instead of waiting for the next input prompt. Evidencecli/src/agent/runAgentPty.ts:358.
Suggested fix:const waitForInputReady = async (timeoutMs = 20000): Promise<boolean> => { const start = Date.now() while (Date.now() - start < timeoutMs) { if (signal?.aborted || !manager.isRunning) return false const idle = Date.now() - lastOutputAt if (hasMarkers) { if (inputReady && idle >= idleReadyMs) return true } else if (sawOutput && idle >= idleReadyMs) { return true } await sleep(80) } return false } const ready = await waitForInputReady() if (!ready || !manager.isRunning || signal?.aborted) { break }
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff. The previous prompt-marker latch issue was partially addressed, but the remaining timeout path still allows queued PTY messages to be submitted before Claude returns to an input prompt.
Testing
- Not run (review automation). Add a
runAgentPtytest where a second queued message waits longer thantimeoutMswhile busy output continues and assert it is not written until a later idle/prompt marker.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] User terminal scrollback is shared across separate terminal instances —
appendUserTerminalOutput()stores output under onlysessionId, but this PR now gives each Terminal page mount a uniqueterminalIdso concurrent viewers of the same session each get their own shell PTY. Output from one shell is appended to the same session buffer and then replayed into a later/new terminal, which can show commands or output that terminal never ran. Evidencehub/src/socket/userTerminalBuffer.ts:16and the unique terminal IDs inweb/src/routes/sessions/terminal.tsx:74.
Suggested fix:const keyFor = (sessionId: string, terminalId: string) => `${sessionId}:${terminalId}` export function appendUserTerminalOutput(sessionId: string, terminalId: string, data: string): void { if (!data) return const key = keyFor(sessionId, terminalId) const next = (buffers.get(key) ?? '') + data buffers.set(key, next.length > MAX_BUFFER_BYTES ? next.slice(-MAX_BUFFER_BYTES) : next) } export function getUserTerminalBuffer(sessionId: string, terminalId: string): string { return buffers.get(keyFor(sessionId, terminalId)) ?? '' } // terminal.ts const buffered = getUserTerminalBuffer(sessionId, terminalId)
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff. The prior PTY queued-message readiness issue is fixed, but the new user-terminal replay buffer can mix output between independent terminal PTYs for the same session.
Testing
- Not run (review automation). Add a hub terminal-handler test with two different
terminalIds for one session, output from terminal A and B, then a new/reconnected terminal, asserting it does not replay the other terminal's buffer.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Minor] Folder-picker draft can drop the PTY choice —
handleChooseFolderClicknow savesstartingMode, but the callback does not depend onstartingMode, so toggling PTY mode and immediately using “Choose folder” can persist the previous closure value. On return to/sessions/new,loadNewSessionFormDraft()restores the stale/default mode and the user may create a non-PTY Claude session despite opting in. Evidenceweb/src/components/NewSession/index.tsx:471.
Suggested fix:const handleChooseFolderClick = useCallback(() => { // existing body }, [ props.onChooseFolder, agent, model, cursorSelectedBase, machineId, effort, modelReasoningEffort, yoloMode, sessionType, worktreeName, trimmedDirectory, startingMode, ])
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff. The prior terminal scrollback finding is fixed; the remaining issue is a UI draft persistence regression around the new PTY checkbox.
Testing
- Not run (review automation). Add/update a NewSession draft test that toggles PTY, invokes the folder picker flow, remounts with a selected directory, and asserts the PTY checkbox remains selected.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] Terminal scrollback buffers are never cleared on web close/disconnect — the new buffer stores up to 256KB for every
sessionId:terminalId, but the normal close path removes the registry entry and sendsterminal:closeto the CLI;TerminalManager.close()then kills the PTY without emittingterminal:exit, so the onlyclearUserTerminalBuffer()call is bypassed. Every terminal route mount creates a fresh UUID, so repeated open/close or mobile reconnects can retain unbounded terminal output in the hub process, including sensitive shell contents. Evidencehub/src/socket/handlers/cli/terminalHandlers.ts:76.
Suggested fix:// hub/src/socket/handlers/terminal.ts import { clearUserTerminalBuffer, getUserTerminalBuffer } from '../userTerminalBuffer' socket.on('terminal:close', (data: unknown) => { const parsed = terminalCloseSchema.safeParse(data) if (!parsed.success) return const entry = resolveEntryForSocket(parsed.data.terminalId) if (!entry) return terminalRegistry.remove(entry.terminalId) clearUserTerminalBuffer(entry.sessionId, entry.terminalId) emitCloseToCli(entry) }) socket.on('disconnect', () => { const removed = terminalRegistry.removeBySocket(socket.id) for (const entry of removed) { clearUserTerminalBuffer(entry.sessionId, entry.terminalId) emitCloseToCli(entry) } // existing agent-terminal idle handling... })
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff. The previous PTY draft dependency issue is fixed; remaining risk is the new user-terminal replay buffer retaining closed terminal output indefinitely.
Testing
- Not run (review automation). Add hub terminal handler coverage for
terminal:close, web socketdisconnect, and registry idle cleanup to assertgetUserTerminalBuffer(sessionId, terminalId)is empty after the terminal is closed.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] Agent terminal stays subscribed to the previous session after switching PTY chats —
AgentTerminalViewis rendered without akey, anduseAgentTerminalSocketonly updatessessionIdRefwhenoptions.sessionIdchanges. If the user opens the agent terminal in one PTY session and selects another PTY session without unmounting this component, the socket remains joined toagent-session:<old>, does not subscribe to the new room, and still writes old-session PTY output into the visible terminal while keystrokes/resizes are emitted with the new session id. Evidenceweb/src/hooks/useAgentTerminalSocket.ts:44.
Suggested fix:<AgentTerminalView key={props.session.id} sessionId={props.session.id} visible={terminalVisible} className={terminalVisible ? 'flex-1 min-h-0' : 'hidden'} />
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff. The prior user-terminal buffer leak is addressed by
TerminalRegistry.onRemove; remaining issue is stale agent-terminal room ownership across session changes.
Testing
- Not run (review automation; dependencies are not installed in this checkout). Add a web hook/component test that switches
sessionIdwhile visible and asserts the old room is unsubscribed/disconnected and the new session subscribes.
HAPI Bot
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current diff at the fresh head. No issues found with confidence >= 80%. The prior agent-terminal remount concern is addressed by
SessionChatkeyingSessionChatInnerbysession.id; residual risk remains in the large PTY lifecycle surface and real Claude hook/TUI timing, which still benefits from end-to-end coverage.
Testing
- Not run (review automation).
HAPI Bot
18a10e2 to
cafe9ff
Compare
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits. Re-reviewed the full current PR diff at the fresh head. No issues found with confidence >= 80%. Residual risk remains in the large PTY lifecycle / hook bridge surface and real Claude TUI timing.
Testing
- Not run (automation;
bunis not available in this runner image).
HAPI Bot
cafe9ff to
c42fa17
Compare
… 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.
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 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.
- Pipe stdin for local Claude processes so chat messages go directly to the running session instead of triggering a mode switch - Add stdin message dedup filter in local launcher scanner callback (guards against user messages without a `message` payload) - Restore lost messages to queue on claudeRemote launch failure - Export extractRawUserTextContent for reuse in dedup logic
On abort, send Esc to interrupt the claude TUI, then (after a 150 ms delay so the TUI finishes restoring its prompt) Ctrl-U to clear the input line and reset the message queue. Emit an abort-restore session event so the web composer can recover the aborted prompt text.
Consume the abort-restore session event: read the last user message text and surface it back in the composer via the existing sendError path, so an aborted prompt is not lost. Skip the signal in the chat timeline since it is a composer side-effect, not a visible event.
abort-restore previously fired unconditionally and the web rebuilt the text by scanning backward for the last user message, so aborting during idle/startup/no-submission could replay an unrelated historical prompt into an empty composer and invite an accidental resend. Track the submitted prompt on the launcher (set on submit, cleared when the turn goes idle) and carry it on the abort-restore event itself. The event is emitted only when a prompt was actually in flight, and the web restores that exact text instead of guessing from history.
A plain "Bash" entry in allowTools is stored in sessionAllowedTools as a name-level allow, but the Bash fast path only consulted the per-command literal/prefix sets — so a session-wide Bash approval was recorded and then ignored, re-prompting on every command. Check sessionAllowedTools for "Bash" before falling back to the per-command match.
PTY mode unset every inherited CLAUDECODE / CLAUDE_CODE_* var before spawning claude so the child saves its transcript. That also dropped CLAUDE_CODE_OAUTH_TOKEN, which the runner uses to pass per-session auth, leaving runner-spawned PTY sessions unauthenticated (login prompt instead of starting). Exclude the token from the unset list.
The pty spawn flow only waited for waitForSessionActive, but `active` is driven by session-alive, which every CLI session emits at construction — before the PTY launcher has spawned claude or reached a usable prompt. So a missing claude binary, auth failure, or early PTY exit could still make the spawn RPC return success and drop the user into an empty terminal. Emit session-ready from the launcher's onReady, and have the spawn flow wait for it (mirroring the resume path): a non-ready outcome now returns a spawn error instead of a false success.
The spawn route passed parsed.data.startingMode positionally into spawnSession's serviceTier slot, leaving the real startingMode parameter undefined. SyncEngine then skipped the PTY readiness wait and the runner defaulted to --hapi-starting-mode remote, so the web PTY checkbox silently launched the SDK remote path. Both values are strings, so the slot mismatch type-checked and stayed latent. Pass undefined for serviceTier and startingMode in its own (13th) slot.
waitForInputReady latched promptSeen permanently, so after the first prompt every later wait returned on just output silence (~500ms). A quiet gap mid-response then satisfied it, letting a queued message be typed into a busy TUI — dropped, appended to the wrong input, or interrupting the turn. Replace the latched check with a re-armable inputReady flag: set by a prompt/idle marker (or the idle watchdog when an idle marker is missed), cleared on a busy marker and on each submit. A queued message now waits for a fresh prompt instead of any silence gap.
handleAbortRequest reset the queue without firing onBatchConsumed, so messages queued behind the aborted turn were cleared on the CLI but left invoked_at=null on the hub: they showed as permanently "queued" in the web and got re-delivered to the fresh prompt by seq-backfill on reconnect, defeating the abort. Collect their localIds and emit them consumed before the reset so the hub clears them.
resumeSession waited only for waitForSessionActive (session-alive), with the ready gate scoped to Cursor ACP. A PTY resume that never reached a usable claude prompt (auth failure, bad --resume, early exit) therefore returned success and dropped the user into a black terminal — the same gap the spawn path already closed. Add the pty session-ready wait to the resume path.
waitForInputReady returned void and fell through after a fixed 20s timeout, and the loop submitted the next queued message regardless. A healthy Claude turn often runs longer than 20s while still emitting busy output, so the queued prompt was typed into the active TUI. Return a boolean and wait until the prompt is actually live: the loop now keeps waiting through a long turn and only stops on process exit/abort (false → break). A startup-only hard cap preserves resilience to a quirky output-less spawn; the message loop has no such cap.
A session can have several independent shell terminals open at once (each a separate PTY, up to maxTerminalsPerSession), but the scrollback buffer was keyed by sessionId alone. One shell's output was appended to the shared buffer and replayed into another terminal that never ran it, and exiting one terminal cleared every terminal's scrollback. Key the buffer by sessionId+terminalId and clear only the exiting terminal's entry.
handleChooseFolderClick saves startingMode into the new-session draft but omitted it from the useCallback deps, so toggling PTY mode then using "Choose folder" persisted a stale startingMode. On return to the new-session form the draft restored the old/default mode, creating a non-PTY session despite the user opting in. Add startingMode to the dependency array.
The scrollback buffer was only cleared on terminal:exit (the shell exiting on its own). The normal web close / disconnect / idle-reap / CLI disconnect paths remove the registry entry and tell the CLI to close the PTY without a terminal:exit, so each buffer (256KB per sessionId:terminalId, fresh UUID per mount) leaked in the hub for the process's life, retaining shell output. Add a TerminalRegistry onRemove callback fired from remove() and wire it to clearUserTerminalBuffer, so every genuine removal releases the buffer. A same-id reconnect re-registers with remove(id, false) so it keeps its scrollback.
Document that SessionChatInner is keyed by session.id (so the subtree remounts on session switch) and AgentTerminalView disconnects its socket on unmount — so the agent terminal never stays subscribed to a previous session's room. Pre-empts the recurring review misread that flags a stale subscription here.
…d-merge Reopening an archived PTY session went through spawn-in-directory, which mints a brand-new hub session id; resumeSession then merged the old row into the new one with deleteOldSession, so the stable id 404'd and a second (new-id) row briefly appeared before the client landed on it. Thread the existing hub session id into the spawn RPC for the PTY reopen/resume path via a dedicated `existingSessionId` field. The runner boots the CLI with `--hapi-session-id`, so the child reuses the existing hub row (bootstrapExistingSession) instead of minting a new id. Same id in, same id out: no new id, no merge, no delete-old, no 404. The child still updates its agentSessionId metadata on the same row, so the auto-dedup contract is unaffected. The field is intentionally separate from the runner's legacy (reserved, previously-ignored) `sessionId` option, which some callers pass an arbitrary tag for; only `existingSessionId` may drive `--hapi-session-id`. Non-PTY resume keeps the legacy fresh-id + merge path unchanged. No schema change.
The interactive PTY TUI (claudePtyLauncher) and the local launcher both write Claude Code's automatic away-summary recap to the session transcript on window blur/focus, but VISIBLE_CLAUDE_SYSTEM_SUBTYPES dropped it before it ever reached the hub. Add it to the whitelist so both launchers forward it like the other system subtypes, and cover the forwarding + Zod passthrough of the recap `content` field with tests.
Once away_summary reaches the hub (previous commit), the web chat still dropped it silently — normalizeAgent had no branch for the subtype, so it fell through to `return null`. Add a `recap` AgentEvent, a normalizeAgent branch mirroring the existing turn_duration/compact subtype branches, and a presentation entry that prefixes the text with `recap:` so it reads distinctly from the manual /recap assistant bubble (which already renders as a normal message). No new render component needed — it flows through the existing generic system-event row (SystemMessage.tsx + getEventPresentation) that every other system subtype already uses.
4646ca8 to
4267bf8
Compare
There was a problem hiding this comment.
Findings
- [Major] PreToolUse approvals time out after 1s before users can respond — PTY permissions are supposed to wait for the web approval modal, and
generateHookSettings.tsgives the Claude hook a 3600s timeout for that reason. However the forwarder still appliesSESSION_HOOK_FORWARD_TIMEOUT_MSto the/hook/pre-tool-usePOST, so any approval that takes more than one second returns the fail-closed deny path before the phone/web user can answer. Evidence:cli/src/claude/utils/sessionHookForwarder.ts:94.
Suggested fix:const SESSION_HOOK_FORWARD_TIMEOUT_MS = 1_000; const PRE_TOOL_USE_FORWARD_TIMEOUT_MS = 3_600_000; function postHook( port: number, token: string, path: string, body: Buffer, timeoutMs = SESSION_HOOK_FORWARD_TIMEOUT_MS ): Promise<{ statusCode?: number; body: string; error: boolean }> { // ... req.setTimeout(timeoutMs, () => { timedOut = true; logError(`Hook request timed out after ${timeoutMs}ms`); req.destroy(); finish({ body: '', error: true }); }); } const response = await postHook(port, token, '/hook/pre-tool-use', body, PRE_TOOL_USE_FORWARD_TIMEOUT_MS);
Questions
- None.
Summary
- Review mode: follow-up after new commits. Found one blocking behavior for the new PTY approval bridge: slow human approvals are denied by the forwarder's 1s HTTP timeout even though the Claude hook itself is configured to wait much longer. Residual risk remains around real Claude PTY timing and browser terminal behavior, but I did not find another issue with confidence >= 80% in the reviewed diff.
Testing
- Not run (review-only; no PR code execution).
HAPI Bot
| } | ||
| finish({ body: '', error: true }); | ||
| }); | ||
| req.setTimeout(SESSION_HOOK_FORWARD_TIMEOUT_MS, () => { |
There was a problem hiding this comment.
[Major] This timeout also applies to /hook/pre-tool-use, so the forwarder gives up after 1s while the hook server is waiting for the web approval. That makes normal phone/web approvals fail closed before a user can respond, despite the PreToolUse hook being configured with a 3600s Claude timeout.
Suggested fix:
const SESSION_HOOK_FORWARD_TIMEOUT_MS = 1_000;
const PRE_TOOL_USE_FORWARD_TIMEOUT_MS = 3_600_000;
function postHook(
port: number,
token: string,
path: string,
body: Buffer,
timeoutMs = SESSION_HOOK_FORWARD_TIMEOUT_MS
): Promise<{ statusCode?: number; body: string; error: boolean }> {
// ...
req.setTimeout(timeoutMs, () => {
timedOut = true;
logError(`Hook request timed out after ${timeoutMs}ms`);
req.destroy();
finish({ body: '', error: true });
});
}
const response = await postHook(port, token, '/hook/pre-tool-use', body, PRE_TOOL_USE_FORWARD_TIMEOUT_MS);The shared postHook helper applied SESSION_HOOK_FORWARD_TIMEOUT_MS (1s) to every forward, including the PreToolUse permission bridge. That bridge waits on the web approval modal — whose hook-side timeout is 3600s — so a 1s cap denied (fail-closed) any approval the user didn't answer within one second. Make the timeout opt-in: only the fire-and-forget SessionStart forward passes it (so a dead hub can't stall startup); PreToolUse forwards with no timeout. Adds a regression guard that a 1.3s approval reply still allows.
There was a problem hiding this comment.
Findings
- [Major] PTY permission-mode changes are acknowledged without changing Claude's live mode —
SetSessionConfigcan update HAPI's currentpermissionMode, but the new PTY apply path only readsmodelandeffortand only sends/modeland/effortto the running TUI. For Claude,planandautoare enforced by Claude itself, so the web UI can reportplan/autowhile the live PTY remains in the previous mode and handles the next turn under the wrong policy. Evidencecli/src/claude/claudePtyLauncher.ts:141.
Suggested fix:setPermissionMode = (mode: PermissionMode): void => { if (mode === this.permissionMode) return; this.permissionMode = mode; this.configChangeHandler?.(); }; // In the PTY launcher, make permission mode part of the live apply contract. // If Claude has no reliable live slash command for this, respawn with --resume. private async applyConfigChange(): Promise<void> { const permissionMode = this.session.getPermissionMode(); if (permissionMode !== this.appliedPermissionMode) { this.appliedPermissionMode = permissionMode; this.ptyAbortController?.abort(); return; } // existing model/effort handling... } private buildSpawnArgs(): string[] { const DYNAMIC = new Set(['--model', '--effort', '--resume', '--permission-mode']); // ...existing base/resume handling... const permissionMode = this.session.getPermissionMode(); return [ ...base, ...(resumeId ? ['--resume', resumeId] : []), ...(permissionMode ? ['--permission-mode', permissionMode] : []), ...(model ? ['--model', model] : []), ...(effort ? ['--effort', effort] : []), ]; }
Summary
- Review mode: follow-up after new commits. The previous PreToolUse timeout finding is no longer present; this pass found one remaining PTY state-sync issue around live permission-mode changes.
Testing
- Not run (automation)
HAPI Bot
| if (!controls) return | ||
| const model = this.session.getModel() | ||
| const effort = this.session.getEffort() | ||
| if (model !== this.appliedModel) { |
There was a problem hiding this comment.
[Major] PTY permission-mode changes are acknowledged without changing Claude's live mode
SetSessionConfig can update HAPI's current permissionMode, but this new PTY apply path only reads model and effort and only sends /model and /effort to the running TUI. For Claude, plan and auto are enforced by Claude itself, so the web UI can report plan/auto while the live PTY remains in the previous mode and handles the next turn under the wrong policy.
Suggested fix:
setPermissionMode = (mode: PermissionMode): void => {
if (mode === this.permissionMode) return;
this.permissionMode = mode;
this.configChangeHandler?.();
};
// In the PTY launcher, make permission mode part of the live apply contract.
// If Claude has no reliable live slash command for this, respawn with --resume.
private async applyConfigChange(): Promise<void> {
const permissionMode = this.session.getPermissionMode();
if (permissionMode !== this.appliedPermissionMode) {
this.appliedPermissionMode = permissionMode;
this.ptyAbortController?.abort();
return;
}
// existing model/effort handling...
}
private buildSpawnArgs(): string[] {
const DYNAMIC = new Set(['--model', '--effort', '--resume', '--permission-mode']);
// ...existing base/resume handling...
const permissionMode = this.session.getPermissionMode();
return [
...base,
...(resumeId ? ['--resume', resumeId] : []),
...(permissionMode ? ['--permission-mode', permissionMode] : []),
...(model ? ['--model', model] : []),
...(effort ? ['--effort', effort] : []),
];
}
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.