Press Ctrl+C in Claude Code and three different things can happen. The interactive-mode docs list all three under one key: "Interrupts a running operation. If nothing is running, the first press clears the prompt input and a second press exits Claude Code."

A plain Unix signal can't make that choice. In a normal shell, Ctrl+C never reaches the program as a key at all. The kernel turns it into SIGINT and hands it to every process in the foreground job, and the program never learns whether you had half a sentence typed.

So when a coding agent clears your draft on the first press and quits on the second, something switched that machinery off. Usually that something is raw mode, and raw mode is also why Esc, Ctrl+C and closing the pane each do something different.

What Ctrl+C does in a plain shell

Your keystrokes go through the kernel's terminal driver before any program reads them. POSIX's terminal interface chapter defines the rule: the INTR character "generates a SIGINT signal which is sent to all processes in the foreground process group" of that terminal. It only applies under one flag: "If ISIG is set, each input character shall be checked against the special control characters INTR, QUIT, and SUSP."

The character itself is one byte. In the ASCII table, C is 0x43 and sits on the same row as ETX ("end of text") at 0x03. The Ctrl key clears the top bits, so Ctrl+C sends 0x03 down the wire. The termios(3) man page describes what happens to that byte when ISIG is on: VINTR will "send a SIGINT signal. Recognized when ISIG is set, and then not passed as input."

You can see both settings with stty:

stty -a 

Look for intr = ^C (the byte that triggers the signal) and isig in the flags list. A leading minus, -isig, means the flag is off. Then turn it off for five seconds and try it:

stty -isig; sleep 5; stty isig 

Press Ctrl+C during the sleep. Nothing stops it. With ISIG off, the 0x03 goes into the input queue as an ordinary character that sleep never reads, and sleep runs its full five seconds. The stty isig at the end puts the signal back.

A single keystroke byte reaching a fork in a cable: one branch becomes a lightning-bolt signal hitting a row of processes, the other delivers the plain byte into one application's hands
With ISIG on, the kernel turns 0x03 into SIGINT for the whole foreground job. With ISIG off, the program gets the byte and decides.

Raw mode hands the byte to the app

A terminal UI wants every key the moment you press it, with no line editing and no echo. That is raw mode, and ISIG goes with it. glibc's cfmakeraw(), per the same termios(3) page, clears ECHO | ECHONL | ICANON | ISIG | IEXTEN in the local flags. libuv, which Node runs on, does the same in its raw mode: tmp.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);.

Node's tty docs say it plainly for setRawMode(): "Ctrl+C will no longer cause a SIGINT when in this mode." The process docs repeat it from the other side: SIGINT "is not generated when terminal raw mode is enabled and Ctrl+C is used."

Here is the agent behaviour from the top of this post, rebuilt by hand. Save it as raw.mjs and run node raw.mjs in a real terminal:

// raw.mjs: Ctrl+C arrives as a byte, and you pick what it means const { stdin } = process; stdin.setRawMode(true); let draft = ''; let armed = false; stdin.on('data', (buf) => { for (const b of buf) { const hex = '0x' + b.toString(16).padStart(2, '0'); if (b !== 0x03) { armed = false; draft += String.fromCharCode(b); console.log(hex, JSON.stringify(draft)); } else if (draft) { draft = ''; armed = true; console.log(hex, 'Ctrl+C: cleared, again to exit'); } else if (armed) { stdin.setRawMode(false); process.exit(0); } else { armed = true; console.log(hex, 'Ctrl+C: press again to exit'); } } }); 

Type a few letters, then Ctrl+C: the draft clears and the process keeps running. Press it again and the script exits. On an empty draft, the first press arms the exit and the second leaves. Enter shows up as 0x0d instead of 0x0a, because libuv's raw mode also clears ICRNL, the flag that would translate it. The script treats bytes as ASCII to stay short, so accented letters will print oddly.

Press Esc and you get 0x1b. Press an arrow key and you get 0x1b followed by more bytes. That overlap is why terminal apps wait a moment after an Esc before acting on it, and why tmux has an escape-time option for how long it "waits after an escape is input to determine if it is part of a function or meta key sequences." Esc can't be a kernel signal at all, so in every agent it means whatever the app decided. In Claude Code, the docs say Esc stops "the current response or tool call mid-turn so you can redirect. Claude keeps the work done so far."

You can check which model your own agent uses without trusting any docs. Run tty in the agent's pane before starting it, then from a second pane read that terminal's flags while the agent sits at its prompt. GNU stty takes -F DEVICE; macOS and the BSDs take -f (FreeBSD stty):

stty -a -F /dev/pts/3 # Linux, use your tty path stty -a -f /dev/ttys003 # macOS 

If you see -isig, Ctrl+C is a byte for that app. If you see isig, the kernel still turns Ctrl+C into SIGINT, and the app either handles that signal itself or lets it end the process.

The npm test the agent spawned

Say the agent runs npm test for you, the suite hangs, and you press Ctrl+C.

In a shell, that keystroke would SIGINT the whole foreground job at once. In raw mode, the kernel sends nothing to anyone. The test runner keeps going until the agent kills it, so every agent has to write its own cancel path. Claude Code's tools reference says "the Bash tool runs each command in a separate process."

Its interactive-mode docs say Ctrl+C "interrupts a running operation" and stop there; they don't say which signal the child gets. They do describe other exits for that process. Ctrl+B moves a running command to the background. By default, a foreground command that hits its timeout is moved to the background instead of being stopped, per the tools reference. Background tasks are cleaned up when Claude Code exits.

Killing the child doesn't kill what it started. Node's child_process docs say subprocess.kill() "sends a signal to the child process," and warn that on Linux "child processes of child processes will not be terminated when attempting to kill their parent." npm test usually starts a shell, which starts the runner, which may start workers.

The fix is a process group. With detached: true, the same docs say, "the child process will be made the leader of a new process group and session." A negative pid in kill(2) then reaches all of them: "If pid is less than -1, then sig is sent to every process in the process group whose ID is -pid."

// run-tests.mjs: Ctrl+C cancels the whole test run (macOS/Linux) import { spawn } from 'node:child_process'; const child = spawn('npm', ['test'], { stdio: ['ignore', 'inherit', 'inherit'], detached: true, // child leads a new process group }); const stopAll = (sig) => { try { process.kill(-child.pid, sig); } catch {} }; process.stdin.setRawMode(true); process.stdin.on('data', (buf) => { if (buf.includes(0x03)) stopAll('SIGINT'); }); process.on('SIGHUP', () => { stopAll('SIGTERM'); process.exit(1); }); child.on('exit', (code, signal) => { process.stdin.setRawMode(false); console.log('\ntests ended:', signal ?? code); process.exit(0); }); 

Two limits. A process inside the run can still start its own session with setsid and leave the group. Claude Code's docs call that exact case out: on macOS and Linux, when a background task is stopped, "processes that detached from the task's shell, such as ones started under setsid or timeout, stop too." And a runner that ignores SIGINT will keep going, so a real agent escalates: SIGINT, then SIGTERM after a grace period, then SIGKILL.

A parent process holding a ring of linked child and grandchild processes, with one grandchild slipping out of the ring on its own thread
A negative pid signals the whole process group. A process that calls setsid has left the ring, and the agent has to track it some other way.

Closing the pane is a different event

Esc and Ctrl+C are bytes the app reads. Closing the pane is the terminal going away, and the app gets no byte at all. It's a hangup, the modern version of what POSIX still calls a modem disconnect: "the SIGHUP signal shall be sent to the controlling process" for that terminal. Today that often means a terminal window or pseudoterminal closing, as the SIGHUP entry on Wikipedia describes.

The bash manual adds the next step for a shell: "Before exiting, an interactive shell resends the SIGHUP to all jobs, running or stopped." And per POSIX _exit(), when a controlling process exits, "the SIGHUP signal shall be sent to each process in the foreground process group."

For a Node agent, the default SIGHUP behaviour is to terminate, "but once a listener has been installed its default behavior will be removed," per the process docs. That is why the script above listens for it. The detached: true that made Ctrl+C easy also put the test run in a new session, outside the shell's jobs and the foreground group, so no hangup signal reaches it. If the agent dies without its SIGHUP handler running, the tests keep going with no terminal.

Side by side:

You do What the agent receives Who decides
Ctrl+C, raw mode byte 0x03 the agent
Esc byte 0x1b the agent
Close the pane SIGHUP via the shell or kernel the agent's handler, or Node's default: exit

One more thing raw mode leaves behind. If an agent crashes without restoring the terminal, your shell can be stuck with no echo. stty sane resets the flags, including isig, icanon and echo. Node's own default handlers for SIGINT and SIGTERM "reset the terminal mode before exiting," per the process docs, but a SIGKILL or a hard native crash gets no such cleanup.

This is all macOS and Linux. On Windows the console has its own switch: with ENABLE_PROCESSED_INPUT, per Microsoft's SetConsoleMode docs, "CTRL+C is processed by the system and is not placed in the input buffer."

Where Hermes IDE fits

I build Hermes IDE, so weigh this with that in mind. Hermes runs each agent's own interface in a real terminal, exactly as it would anywhere else, so everything above applies unchanged and each agent's own docs define what Ctrl+C and Esc do. Hermes never types into a terminal on its own, so it never sends a Ctrl+C or an Esc on your behalf.

Two real agent sessions side by side in Hermes IDE, each in its own worktree, with the sessions sidebar on the left
Codex on orders-types and Claude Code on orders-api, side by side in their own hermes/ worktrees, with the sessions sidebar showing status, context use, cost and branch.

Two things it adds sit outside the keystroke path. On macOS and Linux, agents keep working while Hermes quits, updates or crashes, and you reattach where they left off, so quitting the app doesn't end the agents' terminals. And in the optional Agent view for Claude, where Hermes renders the conversation instead of Claude's terminal interface, a session that ignores Stop gets flagged as not responding, with a Force stop that keeps the conversation. That is the same ask-then-insist pattern as the double Ctrl+C.

Hermes is free to download, source-available under BSL 1.1 on GitHub, and runs on macOS, Windows and Linux. It is not affiliated with Nous Research, Anthropic, OpenAI or any agent vendor.

If this was useful

If you're writing your own agent in Node, the cancel path is part of shipping it: which processes die, which survive, and what the terminal looks like afterwards. AI That Ships covers the other parts of putting an agent in front of real users: evals, guardrails, cost control and deploying on Node.js, all in TypeScript.

AI That Ships — AI in TypeScript

It's book 5 of AI in TypeScript, which starts at your first LLM call and ends with agents in production: all five books here.