Skip to content

[FEATURE]: Agent should use integrated terminal (PTY) instead of spawning new shell processes #23449

Closed as not planned

Description

@herjarsa

Pre-flight Checks

  • I have verified this feature hasn't been suggested before
  • I understand this issue needs status:approved before a PR can be opened

Problem Description

OpenCode has a built-in terminal panel powered by ghostty-web with full PTY support. The terminal panel is accessible via `Ctrl+`` and supports:

  • Multiple tabs
  • 10,000 lines of scrollback
  • Themes (light/dark)
  • WebSocket bridge to /pty/{id}/connect

However, when the agent executes shell commands, it uses the bash tool which spawns a completely new shell process each time. This creates several problems:

  1. No interactive command support - Commands like htop, vim, ssh, mysql that require a TTY can't run
  2. State not shared - Environment variables, working directory changes, and shell state don't persist between commands
  3. Timeout issues - Interactive commands often get killed after the timeout
  4. Waste of resources - New process spawned for every command instead of reusing the PTY

Proposed Solution

Expose the integrated terminal (PTY) to the agent as a tool, so instead of:

Agent -> bash tool -> spawns new process -> returns output

It would be:

Agent -> terminal tool -> connects to existing PTY via WebSocket -> streams I/O

This would enable:

  • Truly interactive commands - ssh, vim, htop, interactive CLIs
  • Persistent shell state - Same session, same environment
  • Better UX - User can see what commands the agent is running in real-time
  • Shared PTY - Agent and user share the same terminal session

Affected Area

  • Core (agent shell execution)
  • ACP protocol (terminal stub mentioned in docs)

References

  • ACP README states: "Terminal support (stub for future)"
  • Commit 21c52fd5c: "fix bash tool getting stuck on interactive commands" shows ongoing work on this area
  • The terminal panel component already exists at packages/app/src/components/terminal.tsx

Related Code

The terminal WebSocket endpoint already exists:

URL: /pty/{id}/connect
Protocol: WebSocket
Backend: ghostty-web + PTY bridge

The bash tool currently spawns via ChildProcessSpawner in packages/opencode/src/tool/bash.ts

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Apr 19, 2026
  2. github-actions commented on Apr 19, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your request is meaningfully different, please clarify what distinguishes it from the above.

  3. herjarsa commented on Apr 19, 2026

    @herjarsa
    Author

    This issue consolidates the following related requests:

    These issues have significant community interest. See also:

    The proposed solution (PTY + WebSocket bridge to existing terminal panel) is technically feasible since the WebSocket endpoint at /pty/{id}/connect already exists in the codebase. This would enable truly interactive commands (ssh, vim, htop), persistent shell state, and shared PTY between agent and user.

  4. herjarsa commented on Apr 20, 2026

    @herjarsa
    Author

    Technical Spec: Agent PTY Integration

    Issue: #23449
    Related: #8097, #9808, #18659, #652, #3212, #1180
    Author: @herjarsa
    Date: 2026-04-20


    1. Problem Statement

    The agent's bash tool (packages/opencode/src/tool/bash.ts) spawns a new child process for every command via ChildProcessSpawner. This means:

    • No TTY → interactive commands (sudo, ssh, vim, htop, gpg) fail or hang
    • No state persistence → env vars, cd, aliases don't carry between calls
    • Timeout kills interactive processes → "bash tool terminated command after exceeding timeout"
    • Every command is isolated → no shared session with user's terminal

    Meanwhile, OpenCode already has a fully functional terminal panel backed by:

    • PTY service (packages/opencode/src/pty/) with Pty.Service, Pty.Proc
    • WebSocket endpoint (/pty/{id}/connect) at packages/opencode/src/server/routes/instance/pty.ts
    • ghostty-web frontend (packages/app/src/components/terminal.tsx)
    • Terminal context (packages/app/src/context/terminal.tsx) with LocalPTY management

    The gap: the agent has NO access to the PTY infrastructure.


    2. Proposed Architecture

    2.1 Overview

    ┌─────────────────────────────────────────────────────────────────────┐
    │                        Current Flow                                 │
    │                                                                     │
    │   Agent → bash tool → ChildProcessSpawner.spawn() → new shell      │
    │                         ❌ No PTY, no TTY, no state                 │
    ├─────────────────────────────────────────────────────────────────────┤
    │                        Proposed Flow                                │
    │                                                                     │
    │   Agent → terminal tool → Pty.Service.create() → PTY session       │
    │              │                  │                                    │
    │              │                  ├→ WebSocket /pty/{id}/connect       │
    │              │                  │   (visible in terminal panel)      │
    │              │                  │                                    │
    │              │                  └→ Pty.Proc.write(command)          │
    │              │                     Pty.Proc.onData() → stream output │
    │              │                     Pty.Proc.onExit() → return result │
    │              │                                                      │
    │              └→ Falls back to bash tool for simple commands         │
    └─────────────────────────────────────────────────────────────────────┘
    

    2.2 Two Modes

    Mode Tool When PTY Interactive
    Non-interactive bash (existing) Simple commands (git, ls, npm) ❌ ❌
    Interactive terminal (new) Requires TTY (sudo, ssh, vim) ✅ ✅

    The agent should choose the appropriate tool based on command analysis. For backward compatibility and simplicity, bash remains the default.


    3. New Tool: terminal

    3.1 Definition

    File: packages/opencode/src/tool/terminal.ts (NEW)
    Description file: packages/opencode/src/tool/terminal.txt (NEW)

    Parameters:

    const Parameters = z.object({
      command: z.string().describe("The command to execute in the PTY session"),
      timeout: z.number().describe("Optional timeout in milliseconds").optional(),
      interactive: z.boolean().describe("Whether to wait for user input or auto-complete").optional(),
      pty_id: z.string().describe("Optional existing PTY session ID to reuse").optional(),
    })

    3.2 Execution Flow

    // Pseudocode — follows existing tool patterns from bash.ts
    export const TerminalTool = Tool.info({
      id: "terminal",
      init: () =>
        Effect.gen(function* () {
          const pty = yield* Pty.Service
    
          return {
            description: TERMINAL_DESCRIPTION,
            parameters: Parameters,
            execute: (params, ctx) =>
              Effect.gen(function* () {
                // 1. Get or create PTY session
                const id = params.pty_id
                  ? yield* pty.get(params.pty_id)
                  : yield* pty.create({
                      cwd: Instance.directory,
                      cols: 80,
                      rows: 24,
                    })
    
                // 2. Write command to PTY
                const proc = yield* pty.proc(id)
                proc.write(params.command + "\n")
    
                // 3. Stream output until prompt returns or timeout
                const output = yield* streamUntilPrompt(proc, params.timeout)
    
                // 4. Return result
                return {
                  title: `Terminal: ${params.command}`,
                  metadata: { output, pty_id: id },
                  output,
                }
              }),
          }
        }),
    })

    3.3 PTY Output Streaming Strategy

    The key challenge: detecting when a command finishes in a PTY.

    Options (in order of preference):

    Strategy How Trade-off
    A. Shell marker Wrap command with echo of unique sentinel: CMD; echo "__OPENCODE_DONE_$?" Reliable, but messes with aliases/functions. Best for non-interactive.
    B. Prompt detection Track PS1/prompt pattern, detect when prompt reappears Natural UX, but prompt detection is fragile across shells.
    C. Timeout + manual Run for timeout, then return output + let user continue Simplest, but agent doesn't know if command finished. Good for truly interactive.
    D. Hybrid A for non-interactive, C for interactive Recommended — best of both worlds.

    Recommended: Strategy D (Hybrid)

    // For commands the agent knows are non-interactive:
    // Wrap with sentinel marker
    proc.write(`${command}\necho "__OPENCODE_EXIT_$?"\n`)
    
    // For truly interactive commands:
    // Stream for timeout, return what we have, let user continue in terminal

    4. Changes Required

    4.1 New Files

    File Purpose
    packages/opencode/src/tool/terminal.ts Terminal tool implementation
    packages/opencode/src/tool/terminal.txt Tool description for LLM

    4.2 Modified Files

    File Change
    packages/opencode/src/tool/registry.ts Register TerminalTool + add to tool map
    packages/opencode/src/tool/bash.ts Add hint: "For interactive commands, use the terminal tool instead"
    packages/opencode/src/acp/client.ts Replace terminal stub with real implementation
    packages/sdk/openapi.json Add terminal tool schema

    4.3 No Changes Needed

    File Why
    packages/opencode/src/pty/pty.ts Interface already supports what we need
    packages/opencode/src/server/routes/instance/pty.ts WebSocket + REST routes already exist
    packages/app/src/components/terminal.tsx Frontend already handles PTY sessions

    5. PTY Service Integration

    The existing Pty.Service already exposes:

    // From packages/opencode/src/pty/pty.ts
    type Proc = {
      pid: number
      onData(listener: (data: string) => void): Disp
      onExit(listener: (event: Exit) => void): Disp
      write(data: string): void
      resize(cols: number, rows: number): void
      kill(signal?: string): void
    }

    The terminal tool simply needs to:

    1. Yield Pty.Service from Effect context (already available)
    2. Create a PTY or reuse an existing one
    3. Write the command via proc.write()
    4. Stream output via proc.onData()
    5. Detect completion (sentinel or timeout)
    6. Return output to the agent

    6. Security Considerations

    Risk Mitigation
    Agent runs sudo without user knowing Permission system: ask() for sudo/interactive commands before execution
    Agent takes over terminal Agent PTY sessions are marked with metadata, visible in terminal panel
    Password leakage in output Strip common password prompts from output before returning to LLM
    Agent leaves zombie PTYs Auto-cleanup: PTY sessions have TTL, cleaned up on session end

    Permission Model

    // Before executing interactive commands, request permission
    yield* ctx.ask({
      id: "terminal.execute",
      sessionID: ctx.sessionID,
      tool: "terminal",
      title: `Execute interactive command: ${command}`,
      type: "command",
      // User sees this in the terminal panel in real-time
    })

    7. ACP Integration

    The ACP client (packages/opencode/src/acp/client.ts) currently has:

    - Terminal support (stub for future)
    

    This spec fills that stub:

    // packages/opencode/src/acp/client.ts
    // Replace stub with:
    async createTerminal(options?: { cwd?: string }): Promise<string> {
      const pty = yield* Pty.Service
      const info = yield* pty.create({ cwd: options?.cwd })
      return info.id
    }
    
    async executeInTerminal(ptyId: string, command: string): Promise<string> {
      // Write command, stream output, return result
    }

    8. Frontend Integration

    The terminal panel already supports multiple tabs. Agent-created PTYs appear as new tabs:

    [Terminal 1] [Agent: sudo apt install] [+]
    

    Changes needed in packages/app/:

    File Change
    src/context/terminal.tsx Add createFromAgent(command) method that creates + auto-titles
    src/pages/session/terminal-panel.tsx Auto-open panel when agent creates a PTY
    src/components/terminal.tsx No changes needed — already handles any PTY

    9. Implementation Priority

    Phase 1: Core (MVP)

    • Create terminal.ts tool with PTY-backed execution
    • Register in registry.ts
    • Sentinel-based completion detection (Strategy A)
    • Permission ask() for interactive commands

    Phase 2: UX

    • Agent PTY tabs appear in terminal panel
    • Auto-open panel when agent uses terminal tool
    • Output streaming in real-time (metadata updates like bash tool)

    Phase 3: Advanced

    • ACP client full implementation (replace stub)
    • Reuse existing PTY sessions (pty_id parameter)
    • Prompt detection (Strategy B) for natural shell interaction
    • Password prompt detection + secure input handoff

    10. Testing Strategy

    Test Type Description
    terminal.test.ts Unit Tool creation, parameter validation
    terminal-pty.test.ts Integration PTY session lifecycle (create, write, read, close)
    terminal-interactive.test.ts E2E sudo, ssh-keygen, gpg commands
    terminal-cleanup.test.ts Integration PTY sessions auto-cleanup on session end
    terminal-existing.test.ts E2E Reuse existing PTY session (pty_id)

    11. Alternative Approaches Considered

    Approach Verdict
    Replace bash tool entirely ❌ Too risky — non-interactive commands work perfectly on bash. Keep both.
    Use Desktop Commander MCP ❌ External dependency, doesn't use OpenCode's PTY infra, no panel integration.
    tmux/screen wrapper ⚠️ Works as workaround but not native UX. Community already does this manually.
    Only add PTY to bash tool ⚠️ Simpler but conflates two different use cases. Separate tool is cleaner.
    New tool + keep bash ✅ Recommended — backward compatible, clean separation of concerns.

    12. Dependencies

    Dependency Status Note
    Pty.Service ✅ Already exists Core PTY management
    WebSocket routes ✅ Already exists /pty/{id}/connect
    ChildProcessSpawner ✅ Available For fallback bash execution
    Effect patterns ✅ Used throughout Effect.gen, InstanceState, Layer
    Permission system ✅ Already exists ctx.ask() for approval flow
    ghostty-web ✅ Already exists Frontend terminal rendering

    13. Open Questions

    1. Should the agent auto-detect interactive commands? Or require explicit interactive: true?
    2. Should agent PTY sessions persist across tool calls? Or create new each time?
    3. How to handle multi-line output streaming? Return chunks or wait for completion?
    4. What's the default timeout for interactive commands? Longer than bash (2min)?
    5. Should user be able to interact with agent's PTY in the terminal panel? Yes — but need clear UX indication that it's an agent session.

    14. References

    • Bash tool: packages/opencode/src/tool/bash.ts
    • PTY service: packages/opencode/src/pty/pty.ts
    • PTY routes: packages/opencode/src/server/routes/instance/pty.ts
    • Terminal component: packages/app/src/components/terminal.tsx
    • Terminal context: packages/app/src/context/terminal.tsx
    • ACP client: packages/opencode/src/acp/client.ts
    • Tool registry: packages/opencode/src/tool/registry.ts
    • Tool interface: packages/opencode/src/tool/tool.ts
    • Commit fixing interactive hang: 21c52fd5c
  5. egdev6 commented on Apr 23, 2026

    @egdev6

    I dug into the current Desktop/agent architecture and I think this issue is pointing in the right direction, but it would help to tighten the implementation path a bit.

    What already exists

    OpenCode already seems to have most of the hard infrastructure:

    • PTY service in packages/opencode/src/pty/*
    • PTY HTTP/WS routes in packages/opencode/src/server/routes/instance/pty.ts
    • Desktop sidecar server in packages/desktop-electron/src/main/server.ts
    • integrated terminal UI already backed by PTY

    So the core problem does not look like “Desktop has no PTY support”.

    The actual gap seems to be that the current agent shell execution path still goes through the one-shot non-interactive bash flow:

    • packages/opencode/src/tool/bash.ts

    That path is still fundamentally different from the integrated PTY terminal path, which explains why interactive flows like:

    • gh issue create --template
    • sudo
    • ssh
    • vim
    • other stdin/TTY-dependent CLIs

    still fail or degrade in Desktop.

    Suggested implementation direction

    I would recommend not replacing the existing bash tool outright at first.

    Instead, the safest path seems to be:

    Phase 1 — add an explicit PTY-backed tool

    Introduce a new tool (for example terminal) with persistent PTY session support, something like:

    • create
    • send
    • read
    • close

    This would let the agent opt into the PTY path only when true interactivity is needed.

    Phase 2 — wire it into Desktop UX

    When the agent creates a PTY-backed session, Desktop should surface/focus that integrated terminal session so the user can observe or take over when needed.

    Phase 3 — keep bash for simple commands

    Keep the existing bash path for non-interactive commands:

    • git status
    • ls
    • npm test
    • etc.

    This avoids forcing PTY/session overhead on every shell call and lowers regression risk.

    Phase 4 — optional smart routing later

    Only after the explicit PTY flow is stable, consider automatic heuristics for routing commands to PTY when they clearly require TTY behavior.

    Why this might be safer than a full bash replacement

    This separates two very different execution modes:

    1. one-shot, output-oriented shell commands
    2. stateful, interactive terminal sessions

    Trying to collapse both into one tool immediately may make the implementation harder to reason about and harder to review.

    A separate PTY-backed tool gives:

    • lower rollout risk
    • clearer semantics
    • easier testing
    • easier fallback if something breaks

    Acceptance criteria I would use for this issue

    I would consider this issue materially solved only if the agent can do all of the following through the PTY-backed path:

    • create a PTY session
    • send follow-up input to the same session
    • read incremental output
    • preserve shell state between calls
    • close/cleanup the session reliably

    And ideally verify against real cases like:

    • gh issue create --template
    • sudo ...
    • ssh ...
    • vim / less

    Bottom line

    I agree with the direction of this issue.

    My only refinement is:

    use the existing PTY infrastructure through an explicit terminal/session tool first, instead of immediately trying to replace all bash execution.

    That seems like the cleanest and lowest-risk path to getting interactive Desktop agent workflows working.

  6. egdev6 commented on Apr 23, 2026

    @egdev6

    I can develop the issue if you reasign me

  7. herjarsa commented on Apr 23, 2026

    @herjarsa
    Author

    Thanks @egdev6 for the direction — this aligns well with what we've already implemented in PR #23794.

    Phase 1 is done in that PR: the erminal tool with create, send,
    ead, close (plus
    un for backward-compat one-shot), backed by the existing Pty.Service. �ash is untouched for simple commands.

    Still need to verify against the real interactive cases you listed (gh issue create --template, sudo, ssh, �im) — planning to do that next.

    Would be great if you could review the PR when you have a chance. Happy to iterate on feedback.

  8. herjarsa commented on Apr 23, 2026

    @herjarsa
    Author

    @egdev6 — Phase 2 (Desktop UX) is now implemented and pushed to PR #23794:

    Phase 1 ✅ (from before): erminal tool with create, send,
    ead, close, backed by Pty.Service

    Phase 2 ✅ (new): Agent-created PTY sessions now:

    • Appear in the Desktop terminal panel automatically
    • Trigger panel auto-open when created
    • Become the active tab so the user can observe/take over

    The implementation:

    • Added sdk.event.on('pty.created') listener in TerminalContext (following same pattern as existing pty.exited)
    • Added deduplication to prevent duplicates
    • Added auto-open effect in TerminalPanel that detects agent sessions by Agent: title prefix

    Both phases are in the same PR. Ready for review when you have a moment!

  9. egdev6 commented on Apr 23, 2026

    @egdev6

    @herjarsa you have the feedback in the PR :)

  10. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  11. Tofu-Xx commented on May 30, 2026

    @Tofu-Xx

    +1, I've encountered this issue when using !yazi and !vim in opencode. The session hangs silently with no error message, requiring manual termination. My workaround is to run these commands directly in the terminal instead of using !.

    Related: #29972

  12. github-actions commented on Jul 30, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions