跳到正文
DEV Community· Aviv Laufer·· 3 小時前AI 評分64

用雲端 VM 和 tmux 讓 Agent 在合上手提電腦後繼續執行

Close the laptop, the agent keeps working

AI 導讀

Aviv Laufer 以雲端 VM 和 tmux 執行長時間 Agent 工作,讓工作在手提電腦合上或斷線後繼續,並可從另一部電腦重新連接。他用 agent-session.sh 經 SSH 連接 VM,並整理 OAuth 回呼轉發、VM 記憶體和命令引號等問題;配套測試套件透過 fake ssh 和 gcloud,在沒有 VM 的情況下測試腳本。

正文

Part 2 of My Agentic Setup. Start from part 1


When an agent runs on my laptop, it stops when I close the laptop. That is fine for a five minute task. It is not fine for an audit that takes an hour, or a crew of agents working through a backlog.

So my long agent work runs on a cloud VM, inside tmux. The laptop is only a window into it.

The whole thing is one command:

agent-session.sh my-vm --gcloud

I'm on the VM. I start omp, give it work, and close the laptop. Tomorrow I run the same command, from the same laptop or the other one, and I'm back in the same place. The agent kept going while I was away.

The idea is simple: the agent runs on the VM inside tmux, and the laptop is only a window, so one command starts the work or brings me back to it from any machine.

Getting there was less simple. Here is what broke first.

What broke

The VM was too small

My first VM was a 1 GB e2-micro. omp got killed by the OOM killer while it was generating its shell completions. Not while running an agent, just while setting up the shell.

Small doesn't mean slow here. It means broken. I moved to a larger VM, and now the troubleshooting table in my docs has this line:

a tool is killed with exit 137    out of memory; check free -h, resize the VM

The login never finished

Every agent CLI logs in on each machine separately. My laptop's logins don't carry over to the VM, and I don't want them to.

claude login opens a browser flow and then waits for the callback on http://localhost:54545/callback. On the VM, that is the VM's localhost. The browser is on my laptop. It authorizes, redirects to port 54545 on the laptop, finds nothing there, and the login never completes. The browser just spins.

Why the login never finished: without --forward-auth the browser redirects to the laptop's localhost, where nothing listens. With --forward-auth an ssh tunnel carries the callback to the VM, where claude login is waiting.

The fix is to forward the callback ports back over ssh. The script has a flag for it:

# Ports the agent CLIs listen on for their OAuth callback. Forwarded so the
# browser on this laptop can reach a login running on the VM.
AUTH_PORTS="54545 54546 8976"

if [ "$FORWARD_AUTH" -eq 1 ]; then
  for port in $AUTH_PORTS; do
    forward_args+=(-L "${port}:localhost:${port}")
  done
fi

So the first time on a new VM I connect with --forward-auth, then run claude login and cursor-agent login inside the session. When a tool has a device code flow, like gh auth login --web, I use that instead. It needs no tunnel at all.

Unknown scope: use

Even with the tunnel, a login can fail with Unknown scope: use.

The cause: the login URL was in a boxed TUI panel. The panel wraps the long URL, and copying the visible lines cuts off the end of the scope list. The browser gets a broken URL.

The fix: copy the link from a plain, wide terminal. Or send the output to a file and take the URL from there:

claude login 2>&1 | tee /tmp/login.txt

Quoting in --run

I wanted to start a job without attaching first:

agent-session.sh my-vm --gcloud --run 'omp "audit the auth module and report back"'

That command goes through three layers: ssh, the shell on the VM, and tmux. Each one has its own quoting rules. A command with quotes inside quotes doesn't survive all three.

The fix is to not quote it at all. The script base64-encodes the command on the laptop and decodes it on the VM:

# The command crosses ssh, the remote shell and tmux, each with its own
# quoting. base64 is the one encoding that survives all three unchanged.
encoded="$(printf '%s' "$RUN" | base64 | tr -d '\n')"
remote="tmux new-session -A -d -s ${session_q}; \
        tmux new-window -t ${session_q} \"sh -lc 'eval \\\"\\\$(printf %s ${encoded} | base64 -d)\\\"; exec \\\$SHELL -l'\"; \
        tmux attach -t ${session_q}"

It is not pretty, but base64 has only letters, digits, +, / and =, so none of the three layers has anything to escape.

The exec $SHELL -l at the end matters too. When the agent finishes, the window stays open with a shell in it, so the final answer is still on screen when I come back.

Running the script on the VM itself

The script is meant to run on the laptop. Run it on the VM by mistake, and gcloud compute ssh tries to ssh from the VM to itself. It generates a new SSH key there and fails on permissions the VM's own service account doesn't have.

The fix is a hostname check. If I'm already on the target machine, the script skips ssh and just attaches:

if [ "$(hostname -s 2>/dev/null)" = "${HOST%%.*}" ]; then
  exec tmux new-session -A -s "$SESSION"
fi

Now the same command does the right thing from both places.

command not found: omp

This one happens on a fresh VM. You connect, tmux starts, then you install the tools. Inside the session, omp is not found.

The tmux server started before the tools were installed, so it still has the old PATH, and every new window gets that old PATH too. Reconnecting doesn't help, because the server is still running.

tmux kill-server

Then connect again.

Smaller ones

Some issues came from setting up the VM, not from running agents on it:

  • A minimal Debian image has no make, and my setup runs make. It is in the first install step now.
  • Debian ships Node 18. Some of my npm tools need 22. Linux takes Node from mise instead of apt.
  • The ssh client forwards a locale the VM didn't generate, which prints a setlocale warning on every login. It is harmless. Installing the locale on the VM, or not forwarding it, makes it go away.

The setup

The VM comes from the same dotfiles

The VM is not special. It gets the same dotfiles as my laptops, with a Linux layer on top. This is the whole OS profile:

# profiles/os/linux.toml
# Linux defaults, targeting headless Debian and Ubuntu LTS hosts.

capabilities = ["cli", "linux"]

[shell]
ssh_use_keychain = false

[packages]
packs = ["base", "agents", "formal", "linux"]

cli without desktop keeps desktop applications off a headless machine. ssh_use_keychain = false matters because UseKeychain is a macOS-only SSH option, and on Linux it makes ssh reject the whole config file.

The agents pack

The agent tools come from a package pack. On macOS most of them are Homebrew formulas. On Linux most don't have an apt package, so each row has an upstream installer:

[[package]]
name = "can1357/tap/omp"
provider = { macos = "brew", linux = "upstream" }
binary = "omp"
install = { linux = "...curl the release binary into ~/.local/bin..." }

[[package]]
name = "claude-code"
provider = { macos = "upstream", linux = "upstream" }
install = "curl -fsSL https://claude.ai/install.sh | bash"
binary = "claude"

[[package]]
name = "node"
provider = { macos = "brew", linux = "upstream" }
min_major = 22
install = { linux = "mise use -g node@lts" }

The bootstrap script shows each upstream command and asks before running it. It also exits non-zero until everything is installed, so a half-installed VM doesn't look finished.

What runs on the VM today:

Tool What it does there Installed on Linux by
tmux keeps sessions alive apt
omp my main agent release binary
Claude Code crew agent its install script
Cursor agent crew agent its install script
Firstmate runs the crew git clone
worktrunk worktrees release tarball
Node for the npm tools mise

New machine, in order

Setting up a VM follows the same eight steps as a laptop:

  1. Base tools: git, ssh, make, Python, chezmoi
  2. A new SSH key for this machine, added to GitHub
  3. Clone the dotfiles
  4. A dry run into a fake home
  5. Declare the machine in a profile
  6. Install the software
  7. Install the configuration
  8. Log in to each tool

Configuration and software never mix. Step 7 installs no packages, and step 6 changes no configuration. Logins are always last, and always per machine. Nothing is copied from another machine.

How the script works

One tmux command does most of the work

The whole idea is one tmux flag:

remote="tmux new-session -A -s $(printf %q "$SESSION")"
exec ssh -t "$HOST" "$remote"

new-session -A attaches to the session if it exists and creates it if it doesn't. So the first connection and every connection after it are the same command.

ssh is only the transport. -t gives it a terminal, because tmux refuses to attach without one.

On GCP the script uses gcloud compute ssh with --tunnel-through-iap. My VM has no external IP, so IAP is how I reach it.

One session, any laptop: the work laptop and the personal laptop both connect over ssh with IAP to my-vm, which is always on. Its tmux server has a session called agents with omp in window 1 and fm payments in window 2, and a session called review with claude.

Less typing

The machine, project and zone are the same every time, so I set them once:

# ~/.zshrc.local
export AGENT_SESSION_HOST=my-vm
export AGENT_SESSION_PROJECT=my-project
export AGENT_SESSION_ZONE=my-zone

Now it is:

agent-session.sh --gcloud            # connect
agent-session.sh --gcloud --list     # what is running

--list is my dashboard:

billing: 1 windows (created Mon Sep 14 09:57:17 2026)
payments: 1 windows (created Mon Sep 14 09:56:16 2026)

Sessions, windows and tabs

These three are easy to mix up, so here is how I use each one:

  • A tmux window (Ctrl-b c, or fm <project>): jobs I want to watch together. Ctrl-b w picks one.
  • A tmux session (--session <name>): jobs I want kept apart, each one reattachable on its own.
  • A Ghostty tab: only a way to see two sessions at the same time. Closing it changes nothing on the VM.

One thing surprises people. Two Ghostty tabs attached to the same session show the same screen, live, with both cursors. That is nice for watching a long run on a second monitor, and useless for doing two things at once. Different work needs --session, not another tab.

What survives

  • Closing the laptop, losing wifi, disconnecting: keeps running.
  • Connecting from the other laptop: same sessions, same screens.
  • Two laptops connected at once: both see the same screen.
  • Rebooting the VM: the agents stop.

The VM is always on, so the last one almost never happens. I have tmux-resurrect and tmux-continuum installed, so if the VM does reboot, the sessions and windows come back. An agent that was in the middle of its work doesn't.

Where I am now

These days I mostly use herdr for this. It does the same job as tmux on the VM, with its own way of saving machines and reconnecting. Use one or the other, not both: herdr inside tmux makes their keyboard shortcuts fight.

The pattern is the same either way. The agent runs on a machine that stays on, and the laptop is only a window.

Try it

The companion repo has a sanitized copy of agent-session.sh and a test suite. The tests run tmux on a private socket with fake ssh and gcloud, so they don't need a VM and don't touch your own tmux server. They check the --run quoting with quotes, $ and backticks, the local attach, --forward-auth and the IAP flags.

cd 02-remote-agents
make test

golem-notes-examples/02-remote-agents

Where do your long agent runs live: your laptop, a VM, something else? Leave a comment.


This is part 2 of my series on running a real agentic dev setup. I publish each part first on Golem Notes, with the full write-up and what's coming next. Subscribe there to get the next one in your inbox: Golem Notes

來源:DEV Community · dev.to