How AI Agents Run Terminal Commands in VS Code
▶ Watch on YouTube & subscribe to The Stack Underflow
When an AI agent runs your build, reads the error output, and retries — all without you touching the keyboard — it looks like magic. It isn’t. The agent is doing exactly what you do: writing into a terminal. But to pull that off reliably, it relies on a five-process architecture, a kernel-level I/O bridge, and a set of invisible escape sequences injected into your shell’s startup script. Understanding those three pieces makes you a sharper developer and a much better debugger when AI-assisted workflows go sideways.
The interesting part is how little “AI” is in the mechanics. Once the model decides what command to run, the execution path is entirely deterministic plumbing: extension APIs, IPC channels, a dedicated PTY host process, and a shell that emits structured markers. The model is just the thing that decides what to type.
The one-sentence version: An AI agent runs terminal commands by calling VS Code’s extension API, which routes the command through a dedicated PTY host process into a pseudo-terminal, then reads back OSC 633 shell integration markers that signal when the command finished and what its exit code was.
The Five-Process Architecture Behind One Terminal Tab
VS Code is not a single process. It is a coordinated cluster of processes, and understanding which one does what is the key to understanding terminal I/O. As of VS Code 1.99+ (March 2025), the relevant processes are:
┌────────────────────────────────────────────────────────────┐
│ VS Code (one window) │
│ │
│ ┌──────────────────┐ ┌────────────────────────────┐ │
│ │ Renderer Process │ │ Extension Host Process │ │
│ │ (Chromium/UI) │ │ (your extensions + agent) │ │
│ │ xterm.js panel │ │ TerminalShellIntegration │ │
│ └────────┬─────────┘ └──────────────┬─────────────┘ │
│ │ display output │ IPC call │
│ │ │ │
│ └─────────────┬─────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ Main / Shared │ │
│ │ Process │ │
│ │ (routes IPC, │ │
│ │ spawns children) │ │
│ └──────────┬──────────┘ │
│ │ fork │
│ ┌──────────▼──────────┐ │
│ │ PTY Host Process │ │
│ │ (node-pty, PtyService, │
│ │ PersistentTerminalProcess) │
│ └──────────┬──────────┘ │
│ │ OS pty/ConPTY │
│ ┌──────────▼──────────┐ │
│ │ Shell Process │ │
│ │ (bash / zsh / pwsh)│ │
│ └─────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The renderer process is a sandboxed Chromium tab. It renders the xterm.js terminal panel but cannot spawn shell processes — it has no filesystem or process access of its own (VS Code’s 2022 sandbox migration locked this down). The extension host process is where your extensions — and the agent — live. It has Node.js access and calls VS Code’s terminal APIs. The PTY host process (a dedicated Node.js fork of the main/shared process) owns all the live shell sessions; its job is to keep heavy terminal I/O from freezing the editor UI. The shell process is the actual shell — bash, zsh, fish, or PowerShell — running as a child of the PTY host.
IPC between these processes uses VS Code’s channel-based RPC system, built on top of Electron’s ipcMain/ipcRenderer transport. Each cross-process service follows a defined channel contract: the extension host calls the PTY host over a message port channel, not directly.
The Pseudo-Terminal: The One Bridge Everything Goes Through
At the OS level, every shell session is anchored to a pseudo-terminal, or pty. A pty is a kernel construct that creates a matched pair of file descriptors: a master end (held by the PTY host process) and a slave end (connected to the shell process). Writing to the master end delivers bytes to the shell as if they came from a keyboard. Reading from the master end delivers everything the shell writes to stdout and stderr.
On Windows, VS Code uses ConPTY (Console Pseudo Terminal, introduced in Windows 10 build 18309) instead of a Unix pty. ConPTY provides the same logical contract — master/slave pair, bidirectional byte stream — but must maintain compatibility with the Windows Console API, which means marker positions can shift during terminal resize. VS Code handles this with two platform-specific heuristic classes: UnixPtyHeuristics registers markers at exact cursor positions, while WindowsPtyHeuristics polls for cursor movement after resize and adjusts marker positions accordingly.
Master end (PTY Host) Slave end (Shell)
───────────────────── ─────────────────
write("npm run build\n") ──► shell receives stdin
◄── shell writes stdout/stderr
read() → "...build output..."
read() → OSC 633 markers
The PTY host manages this via node-pty, the native Node.js binding to the OS pty layer. Each terminal instance corresponds to one TerminalProcess object in the PTY host, which wraps a node-pty instance. For session persistence (the terminal that survives a reload), VS Code wraps this in a PersistentTerminalProcess that can replay buffered output.
Shell Integration: The Invisible Marker Protocol
Knowing how to write a command into the pty is only half the problem. The harder problem is knowing when the command finishes and what its exit code was. A raw pty stream is just bytes — there is no built-in signal saying “the previous command exited with code 1.”
VS Code solves this with shell integration: a startup script injected into your shell’s init sequence that emits structured OSC escape sequences around every command lifecycle event. These sequences are invisible in the rendered terminal (xterm.js filters them out of display) but parseable by the PTY host reading the raw byte stream.
The protocol uses the OSC 633 prefix (Operating System Command code 633, VS Code’s custom extension):
| Sequence | Event | What it signals |
|---|---|---|
OSC 633 ; A ST | PromptStart | The shell prompt has started rendering |
OSC 633 ; B ST | PromptEnd | The prompt has finished; the user (or agent) is typing |
OSC 633 ; C ST | PreExecution | The shell is about to execute the command |
OSC 633 ; E ; cmd [; nonce] ST | CommandLine | The explicit command text, with anti-spoofing nonce |
OSC 633 ; D [; exitcode] ST | CommandFinished | Execution complete; optional exit code integer |
OSC 633 ; P ; Cwd=path ST | Property | Reports current working directory |
OSC 633 ; P ; IsWindows=True ST | Property | Signals ConPTY backend to the parser |
Raw bytes the PTY host reads (simplified):
\e]633;A\a -- PromptStart
user@host:~/project$ \e]633;B\a -- PromptEnd (after prompt text)
\e]633;E;npm run build;abc123\a -- CommandLine with nonce
\e]633;C\a -- PreExecution
npm run build -- visible output begins
... build output ...
ERROR in src/index.ts:42
\e]633;D;1\a -- CommandFinished, exit code 1
The injection mechanism works at shell startup. When VS Code launches a terminal, it sets environment variables (VSCODE_INJECTION=1, VSCODE_SHELL_INTEGRATION=1) and prepends arguments to the shell invocation that source the appropriate integration script before the user’s own rc file runs. The code --locate-shell-integration-path bash command (or zsh, fish, pwsh) shows you exactly where these scripts live. Bash, zsh, fish, and PowerShell each have their own script; the PowerShell script is now code-signed so it runs under Windows’s default RemoteSigned execution policy (VS Code 1.99, March 2025).
Shell Integration Quality Levels and the Three Execution Strategies
As of VS Code 1.99, shell integration is formally tiered into three quality levels that determine how an agent can interact with terminal commands:
| Quality | What is available | Agent capability |
|---|---|---|
| Rich | Full OSC 633 sequence in correct order (E before C, then D) | Exact command text, precise completion event, reliable exit code |
| Basic | Partial sequences (prompt position detected, but E or D may be missing) | Approximate completion; exit code may be unavailable |
| None | No shell integration (raw byte stream only) | No reliable completion detection; output may be misattributed |
You can check your terminal’s quality level by hovering over the terminal tab in VS Code — it shows a tooltip with the current integration status.
These quality levels map directly to the three execution strategies VS Code’s agent terminal tool uses internally:
- RichExecuteStrategy: Relies on the full OSC 633 sequence to know exactly when a command ends and what it returned. The
TerminalShellIntegration.executeCommand()API (stabilized in VS Code 1.93, reliability-improved in 1.99) uses this path. It also now correctly tracks multiple “sub-executions” when a single command line spawns a chain of commands. - BasicExecuteStrategy: Watches for prompt reappearance or partial markers. Less reliable, particularly for long-running commands.
- NoneExecuteStrategy: Falls back to heuristics — timing out after a fixed interval, looking for prompt-like patterns in the raw text stream, or surfacing a warning to the user.
The VS Code shell integration scripts shipped with the editor are designed to guarantee rich quality. If you have a custom shell setup that conflicts (a third-party prompt framework unsetting $VSCODE_SHELL_INTEGRATION, for example), the quality degrades and the agent becomes unreliable.
The Full Loop: Request to Exit Code
Here is the complete path for one agent-initiated command, from the extension host call to the exit code arriving back:
1. Agent (extension host) calls:
terminal.shellIntegration.executeCommand("npm run build")
2. Extension host sends IPC call over channel to PTY host:
"write this command to terminal ID 3"
3. PTY host writes "npm run build\n" to the pty master end.
4. Shell (slave end) receives it, executes the command,
emits stdout/stderr back through the pty.
5. Shell integration script emits OSC 633;C (pre-execution),
then OSC 633;E (command line with nonce).
6. PTY host reads raw bytes from pty master end;
parser strips OSC markers, routes display bytes
to xterm.js in the renderer (via IPC).
7. Build output scrolls in the terminal panel.
8. Shell integration script emits OSC 633;D;1
(CommandFinished, exit code 1).
9. PTY host fires onDidEndTerminalShellExecution event
back to the extension host over IPC.
10. Agent receives the event, reads exitCode: 1,
reads the captured stdout from the execution stream,
sees the error message, decides what to fix.
11. Agent writes a corrective command. Repeat from step 2.
The pty is the choke point for bytes. The OSC 633 markers are the structured protocol layer on top. The IPC channels route everything between processes without privileged access or a secret backdoor.
How to Apply This Right Now
If agent commands hang or silently succeed without output: Check shell integration quality first. Open VS Code, hover over the terminal tab — if it says “Basic” or nothing, your shell init file is likely overriding or failing to source the integration script. Run code --locate-shell-integration-path bash (or zsh, fish, pwsh) and manually verify the file exists and is being sourced.
If you are building an extension that runs commands: Use terminal.shellIntegration.executeCommand() rather than terminal.sendText(). The executeCommand API returns a TerminalShellExecution object with an async iterator over output lines and fires onDidEndTerminalShellExecution with the exit code. sendText is fire-and-forget — it writes bytes but gives you nothing structured back.
If you are debugging why an agent misses output from commands you ran manually: Shell integration markers are emitted by the shell for commands the shell runs in that session. A command you ran in a separate window, or in a session that predates the integration script injection, generates no markers — so the agent has nothing to parse.
On Windows with PowerShell: ConPTY’s marker-position drift means shell integration is more fragile than on Unix. Upgrade to PowerShell 7 (not Windows PowerShell 5.1) and ensure VS Code’s signed PowerShell integration script is being loaded. The GitHub Copilot release tracker has documented repeated cases where PowerShell 5.1 + ConPTY silently drops integration (issue #7261, vscode-copilot-release).
Common Misconceptions
“The AI has a privileged or direct channel into my shell.” The agent has no direct access to the shell process. It writes to the pty input through VS Code’s extension API, which routes through the extension host and PTY host via IPC. The shell process is a grandchild of the editor; there is no shortcut.
“Shell integration is optional for agent commands.” For basic display, yes. For reliable agent use, no. Without rich shell integration, the agent cannot detect command completion, cannot reliably read exit codes, and may stall indefinitely or act on the wrong output. The VS Code Copilot team calls this out explicitly — the agent’s “run in terminal” tool degrades silently when integration quality drops below rich.
“The terminal renderer is where execution happens.” The renderer process is a sandboxed Chromium environment with no process-spawning capability. It displays xterm.js — a display library. All execution happens in the shell process, reached through the PTY host. You could swap out the renderer entirely and the shell would not notice.
“Agents have faster or privileged I/O into the terminal.” Agent writes go through the same pty buffer as your keystrokes, routed through the same IPC channels. If the agent and a human both write to the same pty at the same time, it is a race condition. This is why well-designed agentic tools lock the terminal (mark it as “busy”) while a command is running.
Frequently Asked Questions
Why does the agent sometimes miss output from a command I already ran?
Shell integration markers are emitted per-session, by the integration script that was injected when that terminal opened. If you ran a command in a pre-existing session (before the script was injected), in a separate terminal window, or in a shell that does not have integration active, there are no markers for that run. The agent only “sees” commands executed in sessions where rich integration is active.
What happens when the agent runs an interactive program like vim or a Python REPL?
The agent can write bytes to the pty input — so technically it can send keystrokes to vim. In practice, interactive TUI programs use raw terminal mode and respond to specific control sequences that are difficult to drive reliably from text generation. Most AI coding tools (Copilot, Claude Code, Cursor) detect interactive programs and either surface a warning or hand control back to you. The OSC 633;D marker never fires until the interactive program exits, so the agent just waits — or times out via NoneExecuteStrategy heuristics.
Does this work the same on macOS, Linux, and Windows?
The OSC 633 marker protocol is identical across platforms. The difference is the pty implementation: Unix/macOS use the kernel’s native pty device (via openpty/node-pty), while Windows uses ConPTY. ConPTY’s compatibility mode for the Windows Console API can shift marker cursor positions during terminal resize events, which is why VS Code has the WindowsPtyHeuristics class that re-polls and adjusts after resize. In practice, rich shell integration on PowerShell 7 + ConPTY is reliable; Windows PowerShell 5.1 has known edge cases.
Why did VS Code create OSC 633 instead of using an existing protocol?
There are earlier conventions: iTerm2 uses OSC 133 sequences for similar “semantic zones,” and some terminals support FinalTerm sequences. VS Code adopted a superset under OSC 633 to add VS Code-specific properties (like IsWindows, Cwd, and the anti-spoofing nonce on the E sequence) without conflicting with other terminals’ parsers. Other terminals are supposed to ignore unrecognized OSC codes, so the sequences are inert outside VS Code. As of 2025, VS Code’s shell integration docs explicitly note this extension point and recommend checking $TERM_PROGRAM == "vscode" before sourcing the script.
How does the TerminalShellIntegration API differ from sendText?
terminal.sendText(cmd) writes bytes to the pty and returns immediately — no completion event, no exit code, no output capture. terminal.shellIntegration.executeCommand(cmd) (VS Code 1.93+, improved in 1.99) wraps the command in the shell integration lifecycle: it fires onDidStartTerminalShellExecution when the C marker arrives, streams command output via an async iterator, and fires onDidEndTerminalShellExecution with the exit code when the D marker arrives. For agents, executeCommand is the only API that provides structured lifecycle events; sendText is appropriate only for simple interactive use where you don’t need to observe completion.
Where This Fits in the Series
This tutorial is part 4 of “The Engine Behind Every AI Code Editor”, which dismantles the VS Code stack one layer at a time. The terminal and shell integration layer explained here is the execution foundation — it is what every AI coding tool ultimately relies on when it needs to do real work beyond editing text.
- The editors built on top of this stack are explained in Why Cursor, Windsurf, and Copilot Are All VS Code
- The process isolation that keeps extension crashes from taking down the editor is explained in Why VS Code Survives Extension Crashes
- For how the language intelligence layer works (the LSP, go-to-definition, autocomplete), see How Autocomplete and Go-to-Definition Work
- For how these same agents operate across remote machines (SSH, Dev Containers, Tunnels), see VS Code Remote Development Explained
- For how Claude Code specifically edits files and uses tools beyond the terminal, see How Claude Code Edits Your Repo
Browse all tutorials to follow the full series in order.
Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.
Subscribe on YouTube →