跳到正文
DEV Community· Gabriel Anhaia·· 2 小時前AI 評分70

關閉終端機會向編碼智能體發送 SIGHUP:工作階段主機如何讓它持續執行

Closing a Terminal Sends Your Coding Agent SIGHUP. How Session Hosts Keep It Alive

AI 導讀

關閉終端機會令核心向 shell 發送 SIGHUP,而 shell 通常會再把訊號轉送至編碼智能體,使其程序可能終止。nohup、disown 和 setsid 可令程序避過 SIGHUP 或脱離原終端機,但不保留可互動的終端;tmux 則能讓用戶關閉視窗後重新連回仍運行的工作階段。

正文

You hand a coding agent a long refactor, watch it start on the test suite, and close the terminal window, because the agent is doing the work now. When you open a new window, the process is gone, along with anything it had not written to disk.

Your terminal app never went looking for the agent. Two hops killed it. The kernel told your shell that its terminal had hung up, and the shell passed that on, as SIGHUP, to every job it owned. The usual fixes, nohup, disown, setsid and tmux, each cut a different hop, and only one of them leaves the agent with a terminal you can type into again.

You can reproduce every case below in a few minutes with ps. A sleep stands in for Claude Code or Codex, because it holds still and has no opinion about signals.

Four numbers that decide who dies

Unix groups processes twice. A process group is a job: the processes one command line started, so Ctrl+C reaches all of them. A session is a set of process groups that share one controlling terminal, and the process that created the session is its leader (credentials(7)). In a terminal window, the leader is your shell.

Open a fresh window and look at your shell and one background job:

sleep 600 &
# Linux (procps)
ps -o pid,ppid,pgid,sid,tpgid,tty,comm -p "$$,$!"
# macOS: there is no sid column
ps -o pid,ppid,pgid,tpgid,tty,comm -p "$$,$!"

Four of those columns decide who dies: PID, PGID, SID and TPGID. The shell's PID equals its SID, because it leads the session. sleep has a PGID of its own, because job control puts every job in its own group. Both rows show the same TTY, the slave side of the window's pty. TPGID is the terminal's foreground group: while you sit at the prompt it is the shell's group, and while an agent runs in the foreground it is the agent's.

The macOS line drops a column on purpose. Apple's ps has no sid keyword at all, and the closest one, sess, prints a kernel session pointer in hex instead of an ID (adv_cmds keyword.c). On a Mac, PGID, TPGID and TTY tell you enough.

What happens when the window closes

A terminal window holds the master side of a pseudo-terminal pair. Your shell and everything it starts talk to the slave side. Closing the window closes the master, either because the app closes it or because the app exits.

On Linux, the pty driver reacts to a closing master by calling tty_vhangup on the slave (drivers/tty/pty.c). The hangup walks every process in the terminal's session. It sends SIGHUP and SIGCONT to the session leader, your shell, and clears the controlling terminal of every process in the session that pointed at it (tty_jobctrl.c). From then on a read from that tty returns 0, end of file, and a write fails with EIO (tty_io.c). POSIX states the same rules for any terminal that hangs up: SIGHUP to the controlling process, end of file on read, EIO on write (XBD 11.1.10).

At the moment of the hangup, the kernel signalled one process: the shell. The second hop is the shell's own policy. Bash exits on SIGHUP, and before it does, an interactive bash "resends the SIGHUP to all jobs, running or stopped" (Bash manual). zsh, the default login shell on macOS since 10.15 (Apple), has a HUP option, on by default, that sends SIGHUP to running jobs when the shell exits.

An agent running in the foreground is a job like any other, so it gets the same SIGHUP from the shell. It can get a second one when the shell exits: POSIX says the exit of a controlling process sends SIGHUP to each process in the terminal's foreground group (POSIX _exit), and Linux remembers which group that was when the terminal hung up (tty_jobctrl.c). So a foreground agent is signalled even by a shell that relays nothing. If it ignored both, the terminal under it still answers every read with end of file.

A blank terminal window snapping shut sends one orange signal to a shell figure, which relays smaller signals down to a row of process figures
Two hops: the kernel signals the session leader, and the shell relays SIGHUP to its jobs.

Five sleeps, one closed window

Give each technique its own duration so you can tell the survivors apart. Run these in one window, in bash or zsh:

sleep 601 &                     # plain background job
nohup sleep 602 &               # SIGHUP ignored
sleep 603 & disown              # zsh also: sleep 603 &!
setsid -f sleep 604             # Linux only, util-linux
tmux new -d -s agent 'sleep 605'

On a Mac, replace the setsid line. macOS has the setsid() system call but no setsid command: on Linux that command comes from the util-linux package (setsid(1)), which macOS does not ship. Perl's POSIX module can make the call for you:

perl -MPOSIX -e 'fork and exit; POSIX::setsid(); exec @ARGV' \
  sleep 604 </dev/null >/dev/null 2>&1

The fork matters: setsid() fails with EPERM when the caller already leads a process group (setsid(2)), and a command your shell started as a job leads its group. That is also why the util-linux tool forks on its own in that case, and -f makes it fork every time.

Now close the window, open a new one and list what is left:

ps -A -o pid,ppid,pgid,tty,command | grep '[s]leep 60'

Here is what each line went through:

  • sleep 601 was a job of the shell, so the shell's relayed SIGHUP ended it.
  • sleep 602 got the same relayed SIGHUP and ignored it. nohup sets SIGHUP to ignored before it runs the command, and appends output to nohup.out when stdout is a terminal (POSIX nohup).
  • sleep 603 never got a signal. disown removes the job from the shell's job table so the shell has nothing to relay to it (Bash manual), and the kernel only signalled the session leader.
  • sleep 604 lived in a different session with no controlling terminal (setsid(2)). The hangup walked a session it was never part of.
  • sleep 605 belongs to the tmux server, which gets its own section next.

On Linux, the TTY column for 602 and 603 now reads ?, because the hangup cleared their controlling terminal. One trap with disown: a process group that becomes orphaned while one of its members is stopped gets SIGHUP followed by SIGCONT (POSIX _exit). Press Ctrl+Z on an agent, disown it, close the window, and it can still die.

Surviving the signal is not keeping the terminal

Four sleeps survived, and for sleep that is the whole story. An agent draws a screen, reads your keys and stops to ask before it runs a command. After nohup or disown, the terminal under it is hung up: every read is end of file and every write is EIO. After setsid, there was never a terminal at all. The process may be alive, but nothing can show you its next question or carry your answer back, and what it does about that is up to the agent.

tmux is the only line in the experiment that kept a usable terminal, because the agent never had your window as its terminal. When tmux starts its server, it calls daemon(1, 0) (proc.c), which detaches it from the controlling terminal and points its stdin, stdout and stderr at /dev/null (daemon(3)). Each pane is a separate pseudo-terminal that the server opened and reads from. The tmux you type is only a client: "the server and each client are separate processes which communicate through a socket in /tmp" (tmux(1)).

Closing your window hangs up the window's pty. If you had attached to the session in that window, the tmux client goes down with the shell. On the other side of the socket, the server still holds the pane, and the agent is still running in it. tmux attach -t agent from a new window connects a new client to the same pane. The hangup never reached the agent's terminal, because nothing in the agent's session was attached to your window.

A sturdy black tower holds orange cables plugged into small glowing terminal screens with tiny worker figures, while an app window beside it fades away
The host keeps every pty master. Windows come and go as clients.

The design every agent host needs

Strip tmux down to what made it work, and any app that wants agents to outlive its window needs this list:

  1. A separate long-lived process in its own session that ignores SIGHUP, so a dying UI or terminal can't take it along.
  2. That process holds the pty master for every agent, and each agent runs in its own session with its pty as the controlling terminal.
  3. The UI is a client. It connects over a socket only your user can open, and when it goes away the sessions keep running, unattached.
  4. Output that arrives while nobody is attached goes into a buffer, so a returning client can replay what it missed and ask the program to redraw.
  5. Replacing the UI binary leaves the running host alone, and the two sides check a protocol version before they talk.
  6. The host exits once it has no sessions, so it never lingers as a stray daemon.

I build Hermes IDE, so weigh this part with that in mind. Its session host follows that list, and the source is public. session_host.rs describes hermes-pty-host as a small background process that "owns the PTYs and their programs". The app starts it when the first session needs it, from a copy keyed by the app version, so an update "leaves the running host untouched". The app attaches over a user-only Unix socket with a token, treats a host that speaks another protocol version as unusable, and on return "reattaches to the sessions the host still has, replays the output it missed and nudges the program to repaint".

The host's main.rs calls setsid() under the comment "Own session: the app's terminal or death must never take the host with it." server.rs ignores SIGHUP at startup, keeps a 2 MiB ring of output per session by default, treats a disconnected app as "its session keeps running, unattached", and exits after a stretch with no sessions. The ptys come from wezterm's portable-pty, which calls setsid() in each spawned program before exec and can make the pty its controlling terminal (unix.rs). That is what backs the README line that on macOS and Linux "agents keep working while Hermes quits, updates or crashes, and you reattach where they left off."

Windows is the gap. The same session_host.rs says ConPTY "cannot move between processes", so on Windows the host reports itself unsupported and sessions stay inside the app. On Windows, or when an agent has to outlive the laptop itself, tmux on a remote box over SSH is still the tool. Hermes is free, source-available under BSL 1.1, runs on macOS, Windows and Linux, and is not affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Download it or read the code on GitHub.


If this was useful

The host in the last section is a small system design problem: a process boundary, a socket protocol, a buffer with a size limit, a version check, and a decision about what happens when the client disappears. The System Design Pocket Guide: Interviews walks through 15 real system designs step by step and makes those trade-offs out loud. It is one of 8 short books in the Pocket Guides for Developers series, covering agents, RAG, prompts, observability, events, databases and system design.

System Design Pocket Guide: Interviews — 15 Real System Designs, Step by Step

來源:DEV Community · dev.to