Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ When a coding agent hits a 5-hour or weekly usage limit it hard-stops until you
- **Cross-platform** — native on **Linux, macOS, and Windows** (no WSL required).
- **Graceful handoff** — hotkeys, `detach`/`stop`, auto-detach when you attach, Ctrl-C that detaches rather than kills.
- **Local-LLM reprompt** *(optional)* — a local Ollama model writes a context-aware continuation instruction from the transcript + git diff, validated before use. Falls back to a static prompt on any doubt.
- **Operable & safe** — `status`, desktop + webhook notifications, a `parse` command to tune patterns, and unattended tool-calls **off by default**.
- **Operable & safe** — `status`, desktop + webhook notifications, a `parse` command to tune patterns, and unattended *tool calls* **off by default** (`--yolo` opts in).

See [docs/SPEC.md](docs/SPEC.md) for the full design rationale.

Expand All @@ -57,6 +57,7 @@ Put it on your `PATH`:
```

If the install directory is not already on `PATH`, the command prints the exact `setx PATH` or `export PATH` line to run, plus a reminder to open a new shell.
On macOS/Linux, `install` also tries to add that PATH update to your shell profile automatically (zsh, bash, and sh; other shells get the printed line only). Pass `--no-profile` to skip that and just print the line yourself.

**With the Go toolchain:**

Expand All @@ -78,7 +79,7 @@ SleeperAgent needs a "session backend" — something it can run the agent inside

| OS | Default backend | Setup | Handoff |
|---|---|---|---|
| **Linux / macOS** | `tmux` | install `tmux` (`apt install tmux` / `brew install tmux`) | **full** — agent survives detach; `tmux attach` to take over |
| **Linux / macOS** | `tmux` when available, otherwise `pty` | optional `tmux` (`apt install tmux` / `brew install tmux`) for full handoff | **full** with tmux; reduced with pty |
| **Linux / macOS** | `pty` (`--backend pty`) | none | reduced — agent is bound to the supervisor |
| **Windows** | `pty` (ConPTY) | none (Windows 10 1809+) | reduced — agent is bound to the supervisor |

Expand Down Expand Up @@ -117,7 +118,7 @@ sleeperagent run --agent claude --name mytask
| `rm --name N [--force]` / `rm --all` | Remove a stale/ended instance record (e.g. after the agent exited). `--all` prunes every record with no running supervisor. |
| `agents [--config P]` | List configured adapters and validate that their patterns compile. |
| `parse --agent A "text…"` | Test a captured limit string against an agent's patterns and show the resolved reset. |
| `install [--dir DIR] [--force]` | Copy this binary to a PATH directory. |
| `install [--dir DIR] [--force] [--no-profile]` | Copy this binary to a PATH directory. |
| `version` | Print the build version. |

### `run` flags
Expand All @@ -128,7 +129,7 @@ sleeperagent run --agent claude --name mytask
| `--name` | Instance / tmux session name (default `sleeperagent-<agent>`). |
| `--prompt` | Static resume prompt to inject on reset. |
| `--reprompt` | Local-LLM reprompt, e.g. `ollama:llama3.1` (falls back to static). |
| `--backend` | `tmux` (default on Unix) or `pty` (default on Windows). |
| `--backend` | `tmux` or `pty`. Unix defaults to tmux when it is available, otherwise pty; Windows defaults to pty. |
| `--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. |
Expand All @@ -144,7 +145,7 @@ sleeperagent run --agent codex --prompt "Continue; run the tests after."
# Let a local model write the continuation instruction each reset
sleeperagent run --agent claude --reprompt ollama:llama3.1

# Check on a running instance from another shell
# Check on running instances from any other shell (works on all platforms)
sleeperagent status

# Custom launch command — same Claude adapter, but your own flags / wrapper / binary
Expand All @@ -166,15 +167,15 @@ sleeperagent parse --agent claude "5-hour limit reached ∙ resets 2pm"

SleeperAgent is built to get out of your way. How handoff works depends on the backend:

**tmux backend (Linux/macOS):** the agent lives in a tmux session that **outlives the supervisor**, so nothing is lost when you take over.
**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.
- Reattach anytime with `tmux attach -t <name>`.

**pty / ConPTY backend (default on Windows, optional on Unix):** 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. Use the tmux backend if you need full handoff.
**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.

---

Expand Down
117 changes: 115 additions & 2 deletions cmd/sleeperagent/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func installCmd(args []string) error {
fs := flag.NewFlagSet("install", flag.ContinueOnError)
dir := fs.String("dir", "", "directory to install sleeperagent into")
force := fs.Bool("force", false, "overwrite an existing different file")
noProfile := fs.Bool("no-profile", false, "don't modify shell profile files; just print the PATH line")
if err := fs.Parse(args); err != nil {
return err
}
Expand Down Expand Up @@ -50,7 +51,20 @@ func installCmd(args []string) error {
return nil
}
fmt.Printf("sleeperagent install: %s is not on PATH yet.\n", targetDir)
fmt.Println(pathRemediation(targetDir, runtime.GOOS))
if *noProfile {
fmt.Println(pathRemediation(targetDir, runtime.GOOS))
} else if profile, updated, err := ensurePathInShellProfile(targetDir, runtime.GOOS); err == nil && profile != "" {
if updated {
fmt.Printf("Added it for future shells in %s.\n", profile)
} else {
fmt.Printf("A PATH update for this directory already exists in %s.\n", profile)
}
} else {
if err != nil {
fmt.Printf("Could not update your shell profile automatically: %v\n", err)
}
fmt.Println(pathRemediation(targetDir, runtime.GOOS))
}
fmt.Println("Open a new shell after updating PATH, then run: sleeperagent version")
return nil
}
Expand Down Expand Up @@ -202,5 +216,104 @@ func pathRemediation(dir, goos string) string {
if goos == "windows" {
return fmt.Sprintf("Add it for future shells with:\n setx PATH \"%%PATH%%;%s\"\nOr add it in Settings > System > About > Advanced system settings > Environment Variables.", dir)
}
return fmt.Sprintf("Add it for future shells with:\n export PATH=\"$PATH:%s\"\nPut that line in your shell profile if you want it to persist.", dir)
return fmt.Sprintf("Add it for future shells with:\n %s\nPut that line in your shell profile if you want it to persist.", shellPathLine(dir))
}

func ensurePathInShellProfile(dir, goos string) (string, bool, error) {
if goos == "windows" {
return "", false, nil
}
profile, err := defaultShellProfile(goos)
if err != nil {
return "", false, err
}
if profile == "" {
// Unrecognized shell (fish, nushell, …) — an `export PATH=…` line would
// be wrong or unread there, so leave it to the printed remediation.
return "", false, nil
}
content, err := os.ReadFile(profile)
if err != nil && !os.IsNotExist(err) {
return profile, false, err
}
if profileMentionsDir(string(content), dir) {
return profile, false, nil
}
block := "# Added by sleeperagent install.\n" + shellPathLine(dir) + "\n"
f, err := os.OpenFile(profile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return profile, false, err
}
defer f.Close()
if len(content) > 0 && content[len(content)-1] != '\n' {
block = "\n" + block
}
if _, err := f.WriteString(block); err != nil {
return profile, false, err
}
return profile, true, nil
}

func defaultShellProfile(goos string) (string, error) {
if goos != "windows" {
if home := os.Getenv("HOME"); home != "" {
return shellProfileInHome(home, os.Getenv("SHELL"), goos), nil
}
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return shellProfileInHome(home, os.Getenv("SHELL"), goos), nil
}

// profileMentionsDir reports whether an uncommented line of the profile
// already references dir, so a commented-out or disabled entry doesn't count
// as coverage.
func profileMentionsDir(content, dir string) bool {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
continue
}
if strings.Contains(trimmed, dir) {
return true
}
}
return false
}

// shellProfileInHome returns the profile file the user's shell actually reads
// on new terminals, or "" for shells where an `export PATH` line wouldn't work
// (fish and friends).
func shellProfileInHome(home, shell, goos string) string {
switch filepath.Base(shell) {
case "zsh":
if goos == "darwin" {
return filepath.Join(home, ".zprofile")
}
return filepath.Join(home, ".zshrc")
case "bash":
if goos == "darwin" {
return filepath.Join(home, ".bash_profile")
}
// Linux terminal emulators open interactive non-login shells, which
// read .bashrc; .profile is skipped entirely once .bash_profile exists.
return filepath.Join(home, ".bashrc")
case "sh", "", ".":
if goos == "darwin" {
return filepath.Join(home, ".zprofile")
}
return filepath.Join(home, ".profile")
default:
return ""
}
}

func shellPathLine(dir string) string {
return `export PATH="$PATH":` + shellSingleQuote(dir)
}

func shellSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
111 changes: 111 additions & 0 deletions cmd/sleeperagent/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -112,3 +113,113 @@ func TestPathContainsDir(t *testing.T) {
t.Fatal("expected windows PATH comparison to be case-insensitive")
}
}

func TestEnsurePathInShellProfileAddsMacZProfile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("SHELL", "/bin/zsh")
dir := filepath.Join(home, ".local", "bin")

profile, updated, err := ensurePathInShellProfile(dir, "darwin")
if err != nil {
t.Fatal(err)
}
if !updated {
t.Fatal("expected profile to be updated")
}
wantProfile := filepath.Join(home, ".zprofile")
if profile != wantProfile {
t.Fatalf("profile = %q, want %q", profile, wantProfile)
}
got, err := os.ReadFile(wantProfile)
if err != nil {
t.Fatal(err)
}
wantLine := shellPathLine(dir)
if !strings.Contains(string(got), wantLine) {
t.Fatalf("profile does not contain %q:\n%s", wantLine, got)
}

_, updated, err = ensurePathInShellProfile(dir, "darwin")
if err != nil {
t.Fatal(err)
}
if updated {
t.Fatal("expected second profile update to be idempotent")
}
}

func TestShellProfileInHome(t *testing.T) {
tests := []struct {
shell, goos, want string
}{
{"/bin/zsh", "darwin", ".zprofile"},
{"/usr/bin/zsh", "linux", ".zshrc"},
{"/bin/bash", "darwin", ".bash_profile"},
{"/bin/bash", "linux", ".bashrc"},
{"/bin/sh", "linux", ".profile"},
{"", "darwin", ".zprofile"},
{"/usr/bin/fish", "linux", ""},
{"/usr/bin/fish", "darwin", ""},
}
for _, tt := range tests {
want := tt.want
if want != "" {
want = filepath.Join("home", want)
}
if got := shellProfileInHome("home", tt.shell, tt.goos); got != want {
t.Errorf("shellProfileInHome(%q, %q) = %q, want %q", tt.shell, tt.goos, got, want)
}
}
}

func TestEnsurePathInShellProfileSkipsUnknownShell(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("SHELL", "/usr/bin/fish")

profile, updated, err := ensurePathInShellProfile(filepath.Join(home, "bin"), "linux")
if err != nil {
t.Fatal(err)
}
if profile != "" || updated {
t.Fatalf("expected no profile update for fish, got profile=%q updated=%v", profile, updated)
}
}

func TestEnsurePathInShellProfileIgnoresCommentedMention(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("SHELL", "/bin/bash")
dir := filepath.Join(home, "bin")
rc := filepath.Join(home, ".bashrc")
if err := os.WriteFile(rc, []byte("# "+shellPathLine(dir)+"\n"), 0o644); err != nil {
t.Fatal(err)
}

profile, updated, err := ensurePathInShellProfile(dir, "linux")
if err != nil {
t.Fatal(err)
}
if profile != rc {
t.Fatalf("profile = %q, want %q", profile, rc)
}
if !updated {
t.Fatal("expected commented-out mention to still trigger an update")
}
got, err := os.ReadFile(rc)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(got), "\n"+shellPathLine(dir)) {
t.Fatalf("profile missing uncommented PATH line:\n%s", got)
}
}

func TestShellPathLineQuotesSpaces(t *testing.T) {
got := shellPathLine("/Users/me/Library/Application Support/sleeperagent/bin")
want := `export PATH="$PATH":'/Users/me/Library/Application Support/sleeperagent/bin'`
if got != want {
t.Fatalf("shellPathLine = %q, want %q", got, want)
}
}
26 changes: 22 additions & 4 deletions cmd/sleeperagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Run flags:
--name string instance / tmux session name (default "sleeperagent-<agent>")
--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" (default) or "pty" (no-tmux fallback)
--backend string session backend: "tmux" or "pty" (Unix falls back to pty if tmux is missing)
--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)
Expand Down Expand Up @@ -136,6 +136,7 @@ func runCmd(args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
backendExplicit := flagWasSet(fs, "backend")

cfg, err := config.Load(*cfgPath)
if err != nil {
Expand Down Expand Up @@ -185,7 +186,14 @@ func runCmd(args []string) error {
var afterDetach func(ctx context.Context)
var attachHint string
var restoreLogOutput func()
switch *backend {
selectedBackend := *backend
if selectedBackend == "tmux" && !backendExplicit {
if err := tmux.New(instance, "").Available(); err != nil {
log.Printf("tmux not found on PATH; falling back to --backend pty (install tmux or pass --backend tmux for full handoff)")
selectedBackend = "pty"
}
}
switch selectedBackend {
case "tmux":
tx := tmux.New(instance, "")
if err := tx.Available(); err != nil {
Expand Down Expand Up @@ -229,7 +237,7 @@ func runCmd(args []string) error {
pc.Wait(ctx)
}
default:
return fmt.Errorf("unknown backend %q (use \"tmux\" or \"pty\")", *backend)
return fmt.Errorf("unknown backend %q (use \"tmux\" or \"pty\")", selectedBackend)
}

return watchSession(watchParams{
Expand All @@ -248,8 +256,18 @@ func runCmd(args []string) error {
cwd: cwd,
autoAnswerPrompts: *autoAnswerPrompts,
notifier: buildNotifier(*noNotify, *webhookURL),
transparentTTY: *backend == "pty",
transparentTTY: selectedBackend == "pty",
})
}

func flagWasSet(fs *flag.FlagSet, name string) bool {
seen := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
seen = true
}
})
return seen
}

// watchParams bundles everything watchSession needs so both `run` and
Expand Down
Loading
Loading