From ef7c6200b8c590b32a556cef9a17ce8964952bfd Mon Sep 17 00:00:00 2001 From: Aman Jaiman Date: Fri, 10 Jul 2026 18:19:19 -0400 Subject: [PATCH 1/2] Attach the terminal to tmux runs so Unix matches the Windows experience `sleeperagent run` from a real terminal now drops the user straight into the live tmux session (a supervised `tmux attach` child) while the watchdog keeps polling in the parent, logging to the instance log file. The supervisor's own attach client is hidden from the auto-detach check (attachSuppressingPane); detaching the view (prefix+d) restores console logging with the watchdog still running, and a subsequent manual attach still auto-detaches as a real takeover. New --detached flag restores the console-watch mode; non-TTY runs are unchanged. Verified with a new real-tmux integration test (integration_interactive_attach) plus the existing 8, all green in WSL. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 +++++ README.md | 13 ++-- cmd/sleeperagent/main.go | 92 +++++++++++++++++++++++++- cmd/sleeperagent/main_test.go | 25 ++++++- internal/tmux/tmux.go | 24 +++++++ test/integration_interactive_attach.sh | 76 +++++++++++++++++++++ test/run_all.sh | 2 +- 7 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 test/integration_interactive_attach.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a4af73..e03eb48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- **tmux runs now start attached** — `sleeperagent run` from a real terminal + puts your terminal inside the tmux session immediately (via a supervised + `tmux attach`), so Linux/macOS now match the Windows/ConPTY experience: + prompt and use the agent exactly as if you'd launched it directly, while the + watchdog monitors from the same process and auto-resumes after a limit + reset. The supervisor's own attach client no longer triggers + auto-detach-on-attach; detaching your view (tmux prefix + `d`) keeps the + watchdog running and returns you to its console log, and a *subsequent* + manual `tmux attach` still auto-detaches the watchdog as before. Supervisor + logs are written to the instance log file while the view is attached + (`sleeperagent logs --name N`). Non-TTY runs (scripts, CI) are unchanged. + +### Added +- **`--detached`** flag on `run` — opt out of the new attach-on-start behavior + and watch from the console with the `d`/`q`/`k` hotkeys, as before. + ## [0.3.0] - 2026-07-04 This release also folds in everything previously listed under diff --git a/README.md b/README.md index 8863f5b..79c5d08 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ Optional extras: a local [Ollama](https://ollama.com) for `--reprompt`; `notify- sleeperagent run --agent claude --name mytask ``` -`--agent` picks the adapter (how to detect the limit and drive the agent); by default it also launches that adapter's own command, so the `claude` adapter just runs `claude`. You only need a trailing `-- ` to launch something *different* — your own flags, a wrapper, or another binary (see [Examples](#examples)). Press `d` to detach, or just leave it running. Run `sleeperagent` with no arguments for the built-in help. +`--agent` picks the adapter (how to detect the limit and drive the agent); by default it also launches that adapter's own command, so the `claude` adapter just runs `claude`. You only need a trailing `-- ` to launch something *different* — your own flags, a wrapper, or another binary (see [Examples](#examples)). Run `sleeperagent` with no arguments for the built-in help. + +On every platform, `run` from a real terminal drops you **straight into the live agent**: prompt it and use it exactly as if you'd launched it directly, while SleeperAgent watches in the background and auto-resumes after a limit reset. On the tmux backend, detach your view with the tmux prefix + `d` (watchdog keeps running), or pass `--detached` to skip attaching entirely. --- @@ -130,6 +132,7 @@ sleeperagent run --agent claude --name mytask | `--prompt` | Static resume prompt to inject on reset. | | `--reprompt` | Local-LLM reprompt, e.g. `ollama:llama3.1` (falls back to static). | | `--backend` | `tmux` or `pty`. Unix defaults to tmux when it is available, otherwise pty; Windows defaults to pty. | +| `--detached` | tmux backend: don't attach this terminal to the session; watch from the console with the `d`/`q`/`k` hotkeys instead. | | `--yolo` | Append the agent's skip-permissions flag (**DANGEROUS** — unattended, no prompts). | | `--auto-answer-prompts` | Answer interactive prompts with the first/default option so the run doesn't stall while you're away (**default: on**; pass `=false` to disable). | | `--webhook` | POST notifications to this URL as JSON. | @@ -169,11 +172,11 @@ SleeperAgent is built to get out of your way. How handoff works depends on the b **tmux backend (Linux/macOS):** the agent lives in a tmux session that **outlives the supervisor**, so nothing is lost when you take over. Install tmux (`brew install tmux` on macOS) or pass `--backend tmux` if you specifically need this behavior. -- **Hotkeys** (foreground run): `d`/`q` detach, `k` kills the session (with a `y` confirm). -- **`sleeperagent detach --name X`** from any other shell. -- **Ctrl-C** detaches — it never kills the session. -- **Auto-detach:** the moment you `tmux attach`, SleeperAgent notices and steps aside so you don't both type. +- **You start attached:** `run` from a terminal puts you inside the session immediately — prompt the agent as usual while the watchdog monitors. Detach the *view* with the tmux prefix + `d` (default `Ctrl-b d`); the watchdog keeps running and you get its console log back. +- **`sleeperagent detach --name X`** from any other shell stops watching (the session keeps running). +- **Auto-detach:** if you `tmux attach` *after* detaching your initial view, SleeperAgent treats it as a takeover and steps aside so you don't both type. - Reattach anytime with `tmux attach -t `. +- **`--detached` mode:** the pre-0.4 console view — supervisor logs in your terminal with hotkeys `d`/`q` detach, `k` kill (with a `y` confirm); Ctrl-C detaches, never kills. **pty / ConPTY backend (default on Windows, automatic Unix fallback when tmux is missing):** the agent is a child of the supervisor, so it **can't be handed back interactively**. `detach` gives the terminal back to you until the agent exits; `stop --kill` ends the agent. Use the tmux backend if you need full handoff. diff --git a/cmd/sleeperagent/main.go b/cmd/sleeperagent/main.go index ba6e534..8aae474 100644 --- a/cmd/sleeperagent/main.go +++ b/cmd/sleeperagent/main.go @@ -15,9 +15,13 @@ import ( "runtime" "sort" "strings" + "sync" + "sync/atomic" "syscall" "time" + "golang.org/x/term" + "github.com/amanjaiman/sleeperagent/internal/adapter" "github.com/amanjaiman/sleeperagent/internal/config" "github.com/amanjaiman/sleeperagent/internal/hotkeys" @@ -98,6 +102,10 @@ Run flags: --prompt string static resume prompt to inject on reset --reprompt string local-LLM reprompt, e.g. "ollama:llama3.1" (falls back to static) --backend string session backend: "tmux" or "pty" (Unix falls back to pty if tmux is missing) + --detached tmux backend: don't attach this terminal to the session; + watch from the console instead (default is to attach when + run from a real terminal, so you can prompt the agent + directly while the watchdog monitors) --webhook string POST notifications to this URL --config string path to config.toml (default: OS config dir) --yolo append the agent's skip-permissions flag (DANGEROUS, unattended) @@ -129,6 +137,7 @@ func runCmd(args []string) error { cfgPath := fs.String("config", "", "path to config.toml") reprompt := fs.String("reprompt", "", `local-LLM reprompt, e.g. "ollama:llama3.1"`) backend := fs.String("backend", defaultBackend(), `session backend: "tmux" or "pty"`) + detached := fs.Bool("detached", false, "tmux backend: do not attach this terminal to the session; watch from the console instead") webhookURL := fs.String("webhook", "", "POST notifications to this URL") noNotify := fs.Bool("no-notify", false, "disable desktop notifications") yolo := fs.Bool("yolo", false, "append the agent's skip-permissions flag (DANGEROUS)") @@ -186,6 +195,7 @@ func runCmd(args []string) error { var afterDetach func(ctx context.Context) var attachHint string var restoreLogOutput func() + var interactiveAttach bool selectedBackend := *backend if selectedBackend == "tmux" && !backendExplicit { if err := tmux.New(instance, "").Available(); err != nil { @@ -212,6 +222,54 @@ func runCmd(args []string) error { afterDetach = func(context.Context) { log.Printf("detached. session %q left running — reattach with: %s", instance, tx.AttachHint()) } + + // Interactive attach (the default in a real terminal): put the user's + // terminal inside the session so they can prompt the agent directly, + // while the supervisor keeps polling in this process. Opt out with + // --detached; non-TTY contexts (scripts, CI) keep the detached behavior. + if !*detached && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) { + interactiveAttach = true + restore, rerr := redirectLogsToInstanceFile(instance) + if rerr != nil { + return rerr + } + var restoreOnce sync.Once + restoreLogs := func() { restoreOnce.Do(restore) } + defer restoreLogs() + + // viewing is set before the supervisor starts so its first polls + // already ignore our own attach client (see attachSuppressingPane). + viewing := &atomic.Bool{} + viewing.Store(true) + stopping := &atomic.Bool{} + pane = attachSuppressingPane{Pane: tx, viewing: viewing} + foreground = func(ctx context.Context) { + err := tx.Attach(ctx) + viewing.Store(false) + restoreLogs() + if err != nil { + log.Printf("tmux attach ended: %v", err) + } + if ctx.Err() == nil && !stopping.Load() && tx.HasSession() { + log.Printf("view detached; still watching session %q. Reattach with: %s — stop watching with: sleeperagent detach --name %s", + instance, tx.AttachHint(), instance) + } + } + afterDetach = func(context.Context) { + stopping.Store(true) + // Close our own view so the terminal comes back to this process + // before it exits. If the self-view is already gone (e.g. the + // user detached it and later re-attached manually, triggering + // auto-detach), leave the clients alone — kicking the human off + // the session we just handed them would be rude. + if viewing.Load() { + _ = tx.DetachClients() + waitCondition(func() bool { return !viewing.Load() }, 2*time.Second) + } + restoreLogs() + log.Printf("detached. session %q left running — reattach with: %s", instance, tx.AttachHint()) + } + } case "pty": restore, err := redirectLogsToInstanceFile(instance) if err != nil { @@ -257,9 +315,38 @@ func runCmd(args []string) error { autoAnswerPrompts: *autoAnswerPrompts, notifier: buildNotifier(*noNotify, *webhookURL), transparentTTY: selectedBackend == "pty", + interactiveAttach: interactiveAttach, }) } +// attachSuppressingPane hides the supervisor's own attach client from the +// auto-detach check: a self-attach means "the user is driving the agent while +// the watchdog watches", not "the user took over — step aside". Once the +// self-view exits (viewing is false), a newly attached client is a real +// takeover and auto-detach applies as usual. +type attachSuppressingPane struct { + supervisor.Pane + viewing *atomic.Bool +} + +func (p attachSuppressingPane) ClientAttached() (bool, error) { + if p.viewing.Load() { + return false, nil + } + return p.Pane.ClientAttached() +} + +// waitCondition polls cond until it holds or timeout elapses. +func waitCondition(cond func() bool, timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(50 * time.Millisecond) + } +} + func flagWasSet(fs *flag.FlagSet, name string) bool { seen := false fs.Visit(func(f *flag.Flag) { @@ -289,6 +376,7 @@ type watchParams struct { autoAnswerPrompts bool notifier notify.Notifier transparentTTY bool + interactiveAttach bool } // watchSession runs the supervisor loop against an already-prepared backend. @@ -313,6 +401,8 @@ func watchSession(p watchParams) error { } if p.transparentTTY { log.Printf("pty pass-through: stdin/stdout are reserved for the agent; from another shell use `sleeperagent logs --name %s -f`, `sleeperagent status`, or `sleeperagent detach/stop --name %s`", p.instance, p.instance) + } else if p.interactiveAttach { + log.Printf("interactive attach: your terminal is inside the tmux session; detach the view with the tmux prefix + d (the watchdog keeps running). From another shell: `sleeperagent status`, `sleeperagent detach/stop --name %s`", p.instance) } else { log.Printf("%s", hotkeys.Legend) } @@ -345,7 +435,7 @@ func watchSession(p watchParams) error { // Foreground hotkeys (no-op when stdin isn't a TTY). Raw mode needs CRLF, so // route the logger through a translating writer while hotkeys are active. - if !p.transparentTTY && hotkeys.Listen(ctx, cmds, log.Printf) { + if !p.transparentTTY && !p.interactiveAttach && hotkeys.Listen(ctx, cmds, log.Printf) { log.SetOutput(crlfWriter{os.Stderr}) defer log.SetOutput(os.Stderr) } diff --git a/cmd/sleeperagent/main_test.go b/cmd/sleeperagent/main_test.go index b6de800..ea03ca2 100644 --- a/cmd/sleeperagent/main_test.go +++ b/cmd/sleeperagent/main_test.go @@ -5,6 +5,7 @@ import ( "flag" "os" "strings" + "sync/atomic" "testing" "github.com/amanjaiman/sleeperagent/internal/adapter" @@ -15,8 +16,9 @@ import ( ) type watchTestPane struct { - screen string - ended bool + screen string + ended bool + attached bool } func TestFlagWasSet(t *testing.T) { @@ -40,7 +42,7 @@ func (p *watchTestPane) Capture(int) (string, error) { return p.screen, nil } func (p *watchTestPane) Inject(string, string) error { return nil } func (p *watchTestPane) AttachHint() string { return "tmux attach -t test" } func (p *watchTestPane) Kill() error { return nil } -func (p *watchTestPane) ClientAttached() (bool, error) { return false, nil } +func (p *watchTestPane) ClientAttached() (bool, error) { return p.attached, nil } func (p *watchTestPane) Ended() (bool, error) { return p.ended, nil } type captureNotifier struct { @@ -92,6 +94,23 @@ func TestWatchSessionRemovesRecordAfterCleanSessionEnd(t *testing.T) { } } +// TestAttachSuppressingPaneHidesSelfView confirms the interactive-attach +// wrapper hides our own tmux client from the auto-detach check while the +// self-view is active, and passes real attaches through once it exits. +func TestAttachSuppressingPaneHidesSelfView(t *testing.T) { + viewing := &atomic.Bool{} + p := attachSuppressingPane{Pane: &watchTestPane{attached: true}, viewing: viewing} + + viewing.Store(true) + if attached, _ := p.ClientAttached(); attached { + t.Fatal("self-view should be hidden from the auto-detach check") + } + viewing.Store(false) + if attached, _ := p.ClientAttached(); !attached { + t.Fatal("a client attached after the self-view exits should be reported") + } +} + // TestAutoAnswerPromptsFlagDefaultsToTrue mirrors how runCmd and // attachExistingCmd declare --auto-answer-prompts and confirms it now // defaults to true, and that --auto-answer-prompts=false still opts out. diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index a58f2bf..1a293ee 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -7,9 +7,12 @@ package tmux import ( "bytes" + "context" "fmt" + "os" "os/exec" "strings" + "syscall" "github.com/amanjaiman/sleeperagent/internal/adapter" ) @@ -129,6 +132,27 @@ func (c *Client) injectKeys(keys string) error { return flush() } +// Attach wires this process's terminal to the session via `tmux attach`, so +// the user sees and drives the live agent while the supervisor keeps polling +// in this process. It blocks until the user detaches (prefix-d), the session +// ends, or ctx is cancelled (the client is then asked to detach via SIGTERM, +// which tmux handles by restoring the terminal). +func (c *Client) Attach(ctx context.Context) error { + cmd := exec.CommandContext(ctx, c.bin, "attach-session", "-t", c.Session) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + return cmd.Run() +} + +// DetachClients detaches every client viewing the session, leaving the session +// itself running. +func (c *Client) DetachClients() error { + _, err := c.run("detach-client", "-s", c.Session) + return err +} + // Kill terminates the session (and the agent inside it). func (c *Client) Kill() error { _, err := c.run("kill-session", "-t", c.Session) diff --git a/test/integration_interactive_attach.sh b/test/integration_interactive_attach.sh new file mode 100644 index 0000000..ec7232a --- /dev/null +++ b/test/integration_interactive_attach.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Verifies interactive attach against REAL tmux: `run` from a terminal attaches +# the user's terminal to the session automatically, the supervisor does NOT +# mistake its own attach client for a human takeover, detaching the view keeps +# the watchdog running, and a real (second) attach after that still triggers +# auto-detach as before. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$ROOT/sleeperagent-linux" +export SLEEPERAGENT_STATE_DIR="$(mktemp -d)" +N="ak-ia-$$" +fail=0 + +cleanup() { tmux kill-session -t "$N" 2>/dev/null; rm -rf "$SLEEPERAGENT_STATE_DIR"; } +trap cleanup EXIT +check() { if eval "$2"; then echo " ok: $1"; else echo " FAIL: $1"; fail=1; fi; } + +echo "== run inside a pty so interactive attach engages ==" +# Same TERM trick as the autodetach test: give tmux a terminfo it can attach with. +TERM=xterm script -qfc "$BIN run --name $N -- 'sleep 600'" /dev/null >/tmp/ak_ia_script.log 2>&1 & +SCRIPT_PID=$! + +# The supervisor should create the session AND attach our pty to it. +attached=0 +for _ in $(seq 1 40); do + if [ -n "$(tmux list-clients -t "$N" 2>/dev/null)" ]; then + attached=1 + break + fi + sleep 0.5 +done +check "session auto-attached a client" '[ "$attached" -eq 1 ]' + +# Give the supervisor several poll cycles (default 3s): it must NOT auto-detach +# on its own client. The old behavior would flip the state file to DETACHED. +sleep 10 +check "supervisor still watching (state RUNNING)" '"$BIN" status --name "$N" | grep -q RUNNING' +check "no auto-detach on self view" '! grep -qi "auto-detach" "$SLEEPERAGENT_STATE_DIR/$N.log"' + +echo "== detach the view; the watchdog must keep running ==" +tmux detach-client -s "$N" 2>/dev/null +viewgone=0 +for _ in $(seq 1 20); do + if [ -z "$(tmux list-clients -t "$N" 2>/dev/null)" ]; then + viewgone=1 + break + fi + sleep 0.5 +done +check "view client detached" '[ "$viewgone" -eq 1 ]' +sleep 8 +check "still RUNNING after view detach" '"$BIN" status --name "$N" | grep -q RUNNING' +# After the view detaches, logging returns to the console (the script pty), +# so the "still watching" hint lands in the script log, not the file log. +check "console mentions still watching" 'grep -qia "still watching" /tmp/ak_ia_script.log' + +echo "== a real re-attach after the self-view must auto-detach (old behavior) ==" +TERM=xterm script -qfc "tmux attach -t $N" /dev/null >/tmp/ak_ia_reattach.log 2>&1 & +ATT=$! +detached=0 +for _ in $(seq 1 60); do + if "$BIN" status --name "$N" 2>/dev/null | grep -q DETACHED; then + detached=1 + break + fi + sleep 0.5 +done +check "supervisor auto-detached on real re-attach" '[ "$detached" -eq 1 ]' +check "session still alive (handed to user)" 'tmux has-session -t "$N" 2>/dev/null' + +kill "$ATT" 2>/dev/null +kill "$SCRIPT_PID" 2>/dev/null +echo "---- supervisor log ----"; cat "$SLEEPERAGENT_STATE_DIR/$N.log" 2>/dev/null +if [ "$fail" -eq 0 ]; then echo "RESULT: PASS"; else echo "RESULT: FAIL"; fi +exit "$fail" diff --git a/test/run_all.sh b/test/run_all.sh index 852989f..6db125b 100644 --- a/test/run_all.sh +++ b/test/run_all.sh @@ -4,7 +4,7 @@ cd "$(dirname "$0")/.." fail=0 for s in integration integration_m2 integration_m2_autodetach \ integration_attach integration_codex integration_reprompt integration_pty \ - integration_dead_session; do + integration_dead_session integration_interactive_attach; do sed -i 's/\r$//' "test/$s.sh" 2>/dev/null printf '%-30s ' "$s" if bash "test/$s.sh" >"/tmp/$s.out" 2>&1 && grep -q 'RESULT: PASS' "/tmp/$s.out"; then From 4572fd2bd5d9108fe99e6f62e8546f696a98e385 Mon Sep 17 00:00:00 2001 From: Aman Jaiman Date: Fri, 10 Jul 2026 18:44:42 -0400 Subject: [PATCH 2/2] Address code-review findings on interactive attach - Takeover detection during the self-view: ClientAttached now counts clients while our view is up (via tmux list-clients) instead of masking them all, so a second human attaching still auto-detaches. - Attach failure / view detach falls back to the classic console mode: hotkeys d/q/k engage late via a fallback channel, restoring the kill and detach affordances; runs from inside tmux ($TMUX set) skip the auto-attach entirely since tmux refuses nested attaches. - Supervisor-initiated detach no longer yanks the user out of the view: it flashes a tmux status-line message and waits for them to detach (mirrors the pty backend), and the killed/ended paths wait (bounded) for the view teardown so final messages land on the console. - Extracted setupInteractiveAttach and gave attach-existing (the crash-recovery path) the same interactive attach + --detached flag; deduped the detach message; replaced the 50ms waitCondition poll with a viewDone channel; refreshed the stale pty-only wording in logs.go. Verified: go vet + unit tests green; all 10 integration tests pass in WSL tmux, including a new second-client takeover test. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 37 ++- README.md | 8 +- cmd/sleeperagent/logs.go | 12 +- cmd/sleeperagent/main.go | 246 +++++++++++++----- cmd/sleeperagent/main_test.go | 19 +- internal/tmux/tmux.go | 22 ++ test/integration_interactive_attach.sh | 4 + test/integration_interactive_second_client.sh | 65 +++++ test/run_all.sh | 3 +- 9 files changed, 320 insertions(+), 96 deletions(-) create mode 100644 test/integration_interactive_second_client.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e03eb48..c159554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,32 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Changed -- **tmux runs now start attached** — `sleeperagent run` from a real terminal - puts your terminal inside the tmux session immediately (via a supervised - `tmux attach`), so Linux/macOS now match the Windows/ConPTY experience: - prompt and use the agent exactly as if you'd launched it directly, while the - watchdog monitors from the same process and auto-resumes after a limit - reset. The supervisor's own attach client no longer triggers - auto-detach-on-attach; detaching your view (tmux prefix + `d`) keeps the - watchdog running and returns you to its console log, and a *subsequent* - manual `tmux attach` still auto-detaches the watchdog as before. Supervisor - logs are written to the instance log file while the view is attached - (`sleeperagent logs --name N`). Non-TTY runs (scripts, CI) are unchanged. +- **tmux runs now start attached** — `sleeperagent run` (and `attach-existing`) + from a real terminal puts your terminal inside the tmux session immediately + (via a supervised `tmux attach`), so Linux/macOS now match the Windows/ConPTY + experience: prompt and use the agent exactly as if you'd launched it + directly, while the watchdog monitors from the same process and auto-resumes + after a limit reset. Details: + - The supervisor's own attach client doesn't trigger auto-detach, but a + *second* client attaching during your view — or a manual `tmux attach` + after you detach it — is still treated as a takeover and steps the + watchdog aside. + - Detaching your view (tmux prefix + `d`) keeps the watchdog running and + returns you to its console log with the `d`/`q`/`k` hotkeys active; the + hotkeys also engage if the initial attach fails (e.g. running from inside + an existing tmux session skips the auto-attach entirely). + - Stopping the watchdog while you're inside the view (`sleeperagent + detach`/`stop`) notifies you via the tmux status line and waits for you to + detach instead of yanking your terminal out of the session. + - Supervisor logs go to the instance log file while the view is attached + (`sleeperagent logs --name N`); after the view detaches, the run logs to + its terminal again. + - Non-TTY runs (scripts, CI) are unchanged. ### Added -- **`--detached`** flag on `run` — opt out of the new attach-on-start behavior - and watch from the console with the `d`/`q`/`k` hotkeys, as before. +- **`--detached`** flag on `run` and `attach-existing` — opt out of the new + attach-on-start behavior and watch from the console with the `d`/`q`/`k` + hotkeys, as before. ## [0.3.0] - 2026-07-04 diff --git a/README.md b/README.md index 79c5d08..aef265f 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ On every platform, `run` from a real terminal drops you **straight into the live ## Ways to use it - **`sleeperagent run`** — the default. Launches the agent, watches it in your terminal, detaches with a hotkey, and takes over the moment you're back. Check on it from any other shell with `sleeperagent status`. -- **`sleeperagent attach-existing`** — you already started the agent yourself in tmux and want SleeperAgent to pick up watching it without restarting anything. +- **`sleeperagent attach-existing`** — you already started the agent yourself in tmux and want SleeperAgent to pick up watching it without restarting anything. From a real terminal this also drops you into the live session, same as `run`. --- @@ -172,9 +172,9 @@ SleeperAgent is built to get out of your way. How handoff works depends on the b **tmux backend (Linux/macOS):** the agent lives in a tmux session that **outlives the supervisor**, so nothing is lost when you take over. Install tmux (`brew install tmux` on macOS) or pass `--backend tmux` if you specifically need this behavior. -- **You start attached:** `run` from a terminal puts you inside the session immediately — prompt the agent as usual while the watchdog monitors. Detach the *view* with the tmux prefix + `d` (default `Ctrl-b d`); the watchdog keeps running and you get its console log back. -- **`sleeperagent detach --name X`** from any other shell stops watching (the session keeps running). -- **Auto-detach:** if you `tmux attach` *after* detaching your initial view, SleeperAgent treats it as a takeover and steps aside so you don't both type. +- **You start attached:** `run` from a terminal puts you inside the session immediately — prompt the agent as usual while the watchdog monitors. Detach the *view* with the tmux prefix + `d` (default `Ctrl-b d`); the watchdog keeps running and you get its console log back **with the `d`/`q`/`k` hotkeys active**. (Runs from inside an existing tmux session skip the auto-attach, since tmux refuses nested attaches.) +- **`sleeperagent detach --name X`** from any other shell stops watching (the session keeps running). If you're still inside the view, SleeperAgent tells you via the tmux status line and waits for you to detach rather than yanking your terminal. +- **Auto-detach:** a `tmux attach` *after* you detach your initial view — or a **second** client attaching while your view is up — is treated as a takeover, and SleeperAgent steps aside so you don't both type. - Reattach anytime with `tmux attach -t `. - **`--detached` mode:** the pre-0.4 console view — supervisor logs in your terminal with hotkeys `d`/`q` detach, `k` kill (with a `y` confirm); Ctrl-C detaches, never kills. diff --git a/cmd/sleeperagent/logs.go b/cmd/sleeperagent/logs.go index 7931b14..9063ab4 100644 --- a/cmd/sleeperagent/logs.go +++ b/cmd/sleeperagent/logs.go @@ -14,14 +14,15 @@ import ( "github.com/amanjaiman/sleeperagent/internal/statefile" ) -// instanceLogPath is where a pty-backend instance's supervisor log is written. +// instanceLogPath is where an instance's supervisor log is written: pty-backend +// runs always, and tmux runs while the interactive view is attached — the two +// modes where supervisor output is kept off the terminal so it doesn't corrupt +// the agent's TUI. func instanceLogPath(instance string) string { return filepath.Join(statefile.Dir(), instance+".log") } -// logsCmd prints (and optionally follows) an instance's supervisor log. The log -// exists for pty-backend runs, where supervisor output is kept off the terminal -// so it doesn't corrupt the agent's TUI. +// logsCmd prints (and optionally follows) an instance's supervisor log. func logsCmd(args []string) error { fs := flag.NewFlagSet("logs", flag.ContinueOnError) name := fs.String("name", "", "instance name (required)") @@ -39,7 +40,8 @@ func logsCmd(args []string) error { if err != nil { if os.IsNotExist(err) { return fmt.Errorf("no log file for instance %q at %s\n"+ - "(logs are written by the pty backend; a tmux foreground run logs to its own terminal)", + "(the log is written by pty-backend runs, and by tmux runs while the interactive "+ + "view is attached; after the view detaches, the run logs to its own terminal)", *name, path) } return err diff --git a/cmd/sleeperagent/main.go b/cmd/sleeperagent/main.go index 8aae474..7ce3909 100644 --- a/cmd/sleeperagent/main.go +++ b/cmd/sleeperagent/main.go @@ -196,6 +196,8 @@ func runCmd(args []string) error { var attachHint string var restoreLogOutput func() var interactiveAttach bool + var viewDone <-chan struct{} + var hotkeysFallback <-chan struct{} selectedBackend := *backend if selectedBackend == "tmux" && !backendExplicit { if err := tmux.New(instance, "").Available(); err != nil { @@ -226,49 +228,17 @@ func runCmd(args []string) error { // Interactive attach (the default in a real terminal): put the user's // terminal inside the session so they can prompt the agent directly, // while the supervisor keeps polling in this process. Opt out with - // --detached; non-TTY contexts (scripts, CI) keep the detached behavior. - if !*detached && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) { - interactiveAttach = true - restore, rerr := redirectLogsToInstanceFile(instance) - if rerr != nil { - return rerr - } - var restoreOnce sync.Once - restoreLogs := func() { restoreOnce.Do(restore) } - defer restoreLogs() - - // viewing is set before the supervisor starts so its first polls - // already ignore our own attach client (see attachSuppressingPane). - viewing := &atomic.Bool{} - viewing.Store(true) - stopping := &atomic.Bool{} - pane = attachSuppressingPane{Pane: tx, viewing: viewing} - foreground = func(ctx context.Context) { - err := tx.Attach(ctx) - viewing.Store(false) - restoreLogs() - if err != nil { - log.Printf("tmux attach ended: %v", err) - } - if ctx.Err() == nil && !stopping.Load() && tx.HasSession() { - log.Printf("view detached; still watching session %q. Reattach with: %s — stop watching with: sleeperagent detach --name %s", - instance, tx.AttachHint(), instance) - } - } - afterDetach = func(context.Context) { - stopping.Store(true) - // Close our own view so the terminal comes back to this process - // before it exits. If the self-view is already gone (e.g. the - // user detached it and later re-attached manually, triggering - // auto-detach), leave the clients alone — kicking the human off - // the session we just handed them would be rude. - if viewing.Load() { - _ = tx.DetachClients() - waitCondition(func() bool { return !viewing.Load() }, 2*time.Second) - } - restoreLogs() - log.Printf("detached. session %q left running — reattach with: %s", instance, tx.AttachHint()) + // --detached; non-TTY contexts (scripts, CI) keep the detached behavior, + // and so does a run from inside tmux (nested attach is refused by tmux). + if interactiveWanted(*detached) { + ia, ierr := setupInteractiveAttach(tx, instance, afterDetach) + if ierr != nil { + return ierr } + defer ia.restoreLogs() + pane, foreground, afterDetach = ia.pane, ia.foreground, ia.afterDetach + viewDone, hotkeysFallback = ia.viewDone, ia.hotkeysFallback + interactiveAttach = true } case "pty": restore, err := redirectLogsToInstanceFile(instance) @@ -316,35 +286,116 @@ func runCmd(args []string) error { notifier: buildNotifier(*noNotify, *webhookURL), transparentTTY: selectedBackend == "pty", interactiveAttach: interactiveAttach, + viewDone: viewDone, + hotkeysFallback: hotkeysFallback, }) } -// attachSuppressingPane hides the supervisor's own attach client from the -// auto-detach check: a self-attach means "the user is driving the agent while -// the watchdog watches", not "the user took over — step aside". Once the -// self-view exits (viewing is false), a newly attached client is a real -// takeover and auto-detach applies as usual. +// interactiveWanted reports whether to attach the user's terminal to the tmux +// session: not opted out, a real terminal on both ends, and not already inside +// tmux (tmux refuses a nested attach). +func interactiveWanted(detached bool) bool { + if detached || os.Getenv("TMUX") != "" { + return false + } + return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) +} + +// interactiveView is the wiring for an interactive-attach run: the user's +// terminal lives inside the tmux session (a supervised `tmux attach` child) +// while the supervisor watches from this process. +type interactiveView struct { + pane supervisor.Pane + foreground func(context.Context) + afterDetach func(context.Context) + restoreLogs func() // idempotent; moves logging back to the console + // viewDone closes when the attach child has exited (view detached, session + // gone, or attach failed) — always after restoreLogs has run. + viewDone <-chan struct{} + // hotkeysFallback closes when the view exited but the watchdog is still + // running, so the caller should start console hotkeys. + hotkeysFallback <-chan struct{} +} + +// setupInteractiveAttach redirects supervisor logs to the instance log file +// (they must not scribble over the agent's TUI) and builds the closures that +// run and tear down the self-view. baseAfterDetach is invoked for the final +// user-facing detach message so there is a single source for its wording. +func setupInteractiveAttach(tx *tmux.Client, instance string, baseAfterDetach func(context.Context)) (*interactiveView, error) { + restore, err := redirectLogsToInstanceFile(instance) + if err != nil { + return nil, err + } + var restoreOnce sync.Once + viewDone := make(chan struct{}) + hotkeysFallback := make(chan struct{}) + v := &interactiveView{ + restoreLogs: func() { restoreOnce.Do(restore) }, + viewDone: viewDone, + hotkeysFallback: hotkeysFallback, + } + + // viewing is set before the supervisor starts so its first polls already + // treat our own attach client as the self-view (see attachSuppressingPane). + viewing := &atomic.Bool{} + viewing.Store(true) + stopping := &atomic.Bool{} + v.pane = attachSuppressingPane{Pane: tx, viewing: viewing, clientCount: tx.ClientCount} + + v.foreground = func(ctx context.Context) { + err := tx.Attach(ctx) + viewing.Store(false) + v.restoreLogs() + if ctx.Err() == nil && !stopping.Load() && tx.HasSession() { + if err != nil { + log.Printf("could not attach the view (%v); watching from the console instead", err) + } else { + log.Printf("view detached; still watching session %q. Reattach with: %s", instance, tx.AttachHint()) + } + close(hotkeysFallback) + } + close(viewDone) + } + + v.afterDetach = func(ctx context.Context) { + stopping.Store(true) + // If the user is still in the self-view, don't yank them out of a live + // session mid-keystroke: tell them via the tmux status line and wait + // for them to detach on their own (mirrors the pty backend, where the + // agent keeps the terminal until it exits). + if viewing.Load() { + _ = tx.DisplayMessage("sleeperagent stopped watching — the session is all yours; detach (prefix + d) to close the watchdog process") + select { + case <-viewDone: + case <-ctx.Done(): + } + } + v.restoreLogs() + baseAfterDetach(ctx) + } + return v, nil +} + +// attachSuppressingPane keeps the supervisor's auto-detach check meaningful +// while our own attach client exists: the self-view alone is "the user driving +// the agent while the watchdog watches", not a takeover — but any client +// beyond it is a real takeover. Once the self-view exits, the underlying +// check applies as usual. type attachSuppressingPane struct { supervisor.Pane - viewing *atomic.Bool + viewing *atomic.Bool + clientCount func() (int, error) } func (p attachSuppressingPane) ClientAttached() (bool, error) { if p.viewing.Load() { - return false, nil - } - return p.Pane.ClientAttached() -} - -// waitCondition polls cond until it holds or timeout elapses. -func waitCondition(cond func() bool, timeout time.Duration) { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if cond() { - return + n, err := p.clientCount() + if err != nil { + return false, err } - time.Sleep(50 * time.Millisecond) + return n > 1, nil } + return p.Pane.ClientAttached() } func flagWasSet(fs *flag.FlagSet, name string) bool { @@ -377,6 +428,23 @@ type watchParams struct { notifier notify.Notifier transparentTTY bool interactiveAttach bool + // viewDone / hotkeysFallback are set for interactive-attach runs; see + // interactiveView. Both may be nil. + viewDone <-chan struct{} + hotkeysFallback <-chan struct{} +} + +// waitViewExit blocks (bounded) until the interactive self-view has torn down, +// so final messages land on the user's console instead of racing the view's +// log restore. No-op for non-interactive runs. +func (p watchParams) waitViewExit() { + if p.viewDone == nil { + return + } + select { + case <-p.viewDone: + case <-time.After(5 * time.Second): + } } // watchSession runs the supervisor loop against an already-prepared backend. @@ -440,6 +508,22 @@ func watchSession(p watchParams) error { defer log.SetOutput(os.Stderr) } + // Interactive attach owns stdin while the view is up, so hotkeys start only + // if the view goes away with the watchdog still running (user detached the + // view, or the attach failed) — restoring the classic console controls. + if p.hotkeysFallback != nil { + go func() { + select { + case <-ctx.Done(): + case <-p.hotkeysFallback: + if hotkeys.Listen(ctx, cmds, log.Printf) { + log.SetOutput(crlfWriter{os.Stderr}) + log.Printf("%s", hotkeys.Legend) + } + } + }() + } + // Control-file poller: `sleeperagent detach`/`stop` from another shell drops a // command file that we pick up here and forward to the supervisor. go pollControl(ctx, p.instance, cmds) @@ -469,6 +553,7 @@ func watchSession(p watchParams) error { } if sup.SessionKilled() { + p.waitViewExit() statefile.Remove(p.instance) log.Printf("session %q killed.", p.instance) return nil @@ -476,6 +561,7 @@ func watchSession(p watchParams) error { if sup.SessionEnded() { // The agent exited or the session was killed out from under us; there is // no live session to hand back, so skip the reattach hint. + p.waitViewExit() statefile.Remove(p.instance) log.Printf("session %q ended — the agent exited. Nothing left to watch.", p.instance) return nil @@ -555,6 +641,7 @@ func attachExistingCmd(args []string) error { agent := fs.String("agent", "claude", "agent adapter to use") name := fs.String("name", "", "instance name for status/state (default: target)") target := fs.String("target", "", "tmux target to watch, e.g. mywork:0.1 (required)") + detached := fs.Bool("detached", false, "do not attach this terminal to the session; watch from the console instead") promptText := fs.String("prompt", "", "static resume prompt") cfgPath := fs.String("config", "", "path to config.toml") reprompt := fs.String("reprompt", "", `local-LLM reprompt, e.g. "ollama:llama3.1"`) @@ -603,16 +690,34 @@ func attachExistingCmd(args []string) error { } log.Printf("attaching to existing target %q", *target) + + var pane supervisor.Pane = tx + var foreground func(context.Context) + var interactiveAttach bool + var viewDone, hotkeysFallback <-chan struct{} + afterDetach := func(context.Context) { + log.Printf("detached. target %q left running — reattach with: %s", *target, tx.AttachHint()) + } + if interactiveWanted(*detached) { + ia, ierr := setupInteractiveAttach(tx, instance, afterDetach) + if ierr != nil { + return ierr + } + defer ia.restoreLogs() + pane, foreground, afterDetach = ia.pane, ia.foreground, ia.afterDetach + viewDone, hotkeysFallback = ia.viewDone, ia.hotkeysFallback + interactiveAttach = true + } + return watchSession(watchParams{ - instance: instance, - agent: *agent, - adapter: ad, - pane: tx, - attachHint: tx.AttachHint(), - startSession: nil, // already running - afterDetach: func(context.Context) { - log.Printf("detached. target %q left running — reattach with: %s", *target, tx.AttachHint()) - }, + instance: instance, + agent: *agent, + adapter: ad, + pane: pane, + attachHint: tx.AttachHint(), + startSession: nil, // already running + foreground: foreground, + afterDetach: afterDetach, builder: builder, promptMode: promptMode, resumeText: resumeText, @@ -620,6 +725,9 @@ func attachExistingCmd(args []string) error { cwd: cwd, autoAnswerPrompts: *autoAnswerPrompts, notifier: buildNotifier(*noNotify, *webhookURL), + interactiveAttach: interactiveAttach, + viewDone: viewDone, + hotkeysFallback: hotkeysFallback, }) } diff --git a/cmd/sleeperagent/main_test.go b/cmd/sleeperagent/main_test.go index ea03ca2..8890f1a 100644 --- a/cmd/sleeperagent/main_test.go +++ b/cmd/sleeperagent/main_test.go @@ -95,15 +95,26 @@ func TestWatchSessionRemovesRecordAfterCleanSessionEnd(t *testing.T) { } // TestAttachSuppressingPaneHidesSelfView confirms the interactive-attach -// wrapper hides our own tmux client from the auto-detach check while the -// self-view is active, and passes real attaches through once it exits. +// wrapper discounts exactly one client (our own view) from the auto-detach +// check while the self-view is active: the self-view alone is not a takeover, +// a second client is, and once the self-view exits the underlying check +// applies unchanged. func TestAttachSuppressingPaneHidesSelfView(t *testing.T) { viewing := &atomic.Bool{} - p := attachSuppressingPane{Pane: &watchTestPane{attached: true}, viewing: viewing} + clients := 1 + p := attachSuppressingPane{ + Pane: &watchTestPane{attached: true}, + viewing: viewing, + clientCount: func() (int, error) { return clients, nil }, + } viewing.Store(true) if attached, _ := p.ClientAttached(); attached { - t.Fatal("self-view should be hidden from the auto-detach check") + t.Fatal("the self-view alone should not read as a takeover") + } + clients = 2 + if attached, _ := p.ClientAttached(); !attached { + t.Fatal("a second client during the self-view is a takeover and should be reported") } viewing.Store(false) if attached, _ := p.ClientAttached(); !attached { diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 1a293ee..fb0803b 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -153,6 +153,28 @@ func (c *Client) DetachClients() error { return err } +// ClientCount reports how many clients are attached to the session. It lets a +// caller that owns one attach client of its own distinguish "just my view" +// from "my view plus someone else". +func (c *Client) ClientCount() (int, error) { + out, err := c.run("list-clients", "-t", c.Session) + if err != nil { + return 0, err + } + out = strings.TrimSpace(out) + if out == "" { + return 0, nil + } + return len(strings.Split(out, "\n")), nil +} + +// DisplayMessage flashes text in the attached clients' tmux status line — the +// only channel to a user who is inside the session while our logs go elsewhere. +func (c *Client) DisplayMessage(text string) error { + _, err := c.run("display-message", "-t", c.Session, text) + return err +} + // Kill terminates the session (and the agent inside it). func (c *Client) Kill() error { _, err := c.run("kill-session", "-t", c.Session) diff --git a/test/integration_interactive_attach.sh b/test/integration_interactive_attach.sh index ec7232a..022ad56 100644 --- a/test/integration_interactive_attach.sh +++ b/test/integration_interactive_attach.sh @@ -54,6 +54,10 @@ check "still RUNNING after view detach" '"$BIN" status --name "$N" | grep -q RUN # After the view detaches, logging returns to the console (the script pty), # so the "still watching" hint lands in the script log, not the file log. check "console mentions still watching" 'grep -qia "still watching" /tmp/ak_ia_script.log' +# Hotkeys fall back on once the view is gone, restoring the classic console +# controls; the legend is printed when they engage. +sleep 2 +check "hotkey legend printed after view detach" 'grep -qa "\[d\]etach" /tmp/ak_ia_script.log' echo "== a real re-attach after the self-view must auto-detach (old behavior) ==" TERM=xterm script -qfc "tmux attach -t $N" /dev/null >/tmp/ak_ia_reattach.log 2>&1 & diff --git a/test/integration_interactive_second_client.sh b/test/integration_interactive_second_client.sh new file mode 100644 index 0000000..e54a58d --- /dev/null +++ b/test/integration_interactive_second_client.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Verifies takeover detection during the self-view against REAL tmux: a second +# client attaching while the interactive self-view is up is a real takeover, so +# the supervisor auto-detaches — but it must NOT yank the humans out of the +# session; it waits for the view to close before exiting. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$ROOT/sleeperagent-linux" +export SLEEPERAGENT_STATE_DIR="$(mktemp -d)" +N="ak-i2c-$$" +fail=0 + +cleanup() { tmux kill-session -t "$N" 2>/dev/null; rm -rf "$SLEEPERAGENT_STATE_DIR"; } +trap cleanup EXIT +check() { if eval "$2"; then echo " ok: $1"; else echo " FAIL: $1"; fail=1; fi; } + +clients() { tmux list-clients -t "$N" 2>/dev/null | grep -c .; } + +echo "== start interactive run, then attach a second client ==" +TERM=xterm script -qfc "$BIN run --name $N -- 'sleep 600'" /dev/null >/tmp/ak_i2c_run.log 2>&1 & +RUN=$! +attached=0 +for _ in $(seq 1 40); do + if [ "$(clients)" -ge 1 ]; then attached=1; break; fi + sleep 0.5 +done +check "self-view attached" '[ "$attached" -eq 1 ]' + +TERM=xterm script -qfc "tmux attach -t $N" /dev/null >/tmp/ak_i2c_att.log 2>&1 & +ATT=$! +two=0 +for _ in $(seq 1 40); do + if [ "$(clients)" -ge 2 ]; then two=1; break; fi + sleep 0.5 +done +check "second client attached" '[ "$two" -eq 1 ]' + +echo "== supervisor must detect the takeover and step aside ==" +detached=0 +for _ in $(seq 1 60); do + if "$BIN" status --name "$N" 2>/dev/null | grep -q DETACHED; then detached=1; break; fi + sleep 0.5 +done +check "supervisor auto-detached on second client" '[ "$detached" -eq 1 ]' +check "session still alive" 'tmux has-session -t "$N" 2>/dev/null' +check "log mentions auto-detach" 'grep -qi "auto-detach" "$SLEEPERAGENT_STATE_DIR/$N.log"' + +echo "== supervisor waits for the view instead of yanking it ==" +# The self-view must still be attached (not force-detached) while the +# supervisor parks; only when the clients leave does the process exit. +check "clients not yanked" '[ "$(clients)" -ge 2 ]' +check "supervisor process still parked" 'kill -0 "$RUN" 2>/dev/null' +tmux detach-client -s "$N" 2>/dev/null +exited=0 +for _ in $(seq 1 30); do + if ! kill -0 "$RUN" 2>/dev/null; then exited=1; break; fi + sleep 0.5 +done +check "supervisor exited after view closed" '[ "$exited" -eq 1 ]' + +kill "$ATT" 2>/dev/null +echo "---- supervisor log ----"; cat "$SLEEPERAGENT_STATE_DIR/$N.log" 2>/dev/null +if [ "$fail" -eq 0 ]; then echo "RESULT: PASS"; else echo "RESULT: FAIL"; fi +exit "$fail" diff --git a/test/run_all.sh b/test/run_all.sh index 6db125b..6158691 100644 --- a/test/run_all.sh +++ b/test/run_all.sh @@ -4,7 +4,8 @@ cd "$(dirname "$0")/.." fail=0 for s in integration integration_m2 integration_m2_autodetach \ integration_attach integration_codex integration_reprompt integration_pty \ - integration_dead_session integration_interactive_attach; do + integration_dead_session integration_interactive_attach \ + integration_interactive_second_client; do sed -i 's/\r$//' "test/$s.sh" 2>/dev/null printf '%-30s ' "$s" if bash "test/$s.sh" >"/tmp/$s.out" 2>&1 && grep -q 'RESULT: PASS' "/tmp/$s.out"; then