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
19 changes: 6 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,10 @@ jobs:
- name: install tmux
run: sudo apt-get update && sudo apt-get install -y tmux
- name: build linux binary
run: go build -o sleeperagent-linux ./cmd/sleeperagent
# The version stamp matters: integration_update.sh exercises the
# self-update flow, which a "dev" build refuses by design.
run: go build -ldflags "-X main.version=0.0.0-test" -o sleeperagent-linux ./cmd/sleeperagent
- name: run integration scripts
run: |
set -e
for s in test/integration.sh \
test/integration_m2.sh \
test/integration_m2_autodetach.sh \
test/integration_attach.sh \
test/integration_codex.sh \
test/integration_reprompt.sh \
test/integration_pty.sh; do
echo "=== $s ==="
bash "$s"
done
# run_all.sh is the single source of truth for the suite, so new
# scripts can't silently drift out of CI.
run: bash test/run_all.sh
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.5.0] - 2026-07-10

### Added
- **Self-update** — new `sleeperagent update` command downloads the latest
GitHub release for your OS/arch, verifies it against the release's
`checksums.txt`, and atomically replaces the executable (on Windows the
running exe is parked as `.old` and swept up on the next start). `--check`
only reports. Release builds also offer this at startup, Codex-style:
`run`/`attach-existing` from a real terminal checks for a newer release at
most once a day and asks `Update now? [Y/n]` before the session launches.
The check is skipped for source (`dev`) builds and non-TTY runs, never
blocks startup on a slow or offline network (3s timeout, result cached),
and can be disabled with `check = false` under `[update]` in config.toml or
the `SLEEPERAGENT_NO_UPDATE_CHECK` env var. A failed or declined update
never touches the existing binary.

### Fixed
- CI now runs the integration suite through `test/run_all.sh`, so new
integration scripts can't silently drift out of CI (dead-session and the
interactive-attach tests were missing from the workflow's hand-kept list).

## [0.4.0] - 2026-07-10

### Changed
Expand Down Expand Up @@ -113,6 +134,7 @@ upgrading.
times are cleared once the agent resumes, so `status` reports `RUNNING` with no
leftover countdown.

[Unreleased]: https://github.com/amanjaiman/sleeperagent/compare/v0.4.0...main
[Unreleased]: https://github.com/amanjaiman/sleeperagent/compare/v0.5.0...main
[0.5.0]: https://github.com/amanjaiman/sleeperagent/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/amanjaiman/sleeperagent/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/amanjaiman/sleeperagent/compare/v0.2.0...v0.3.0
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ 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.

**Staying up to date** — release builds check GitHub for a newer version when you start a run from a real terminal (at most once a day) and offer to install it; answer `Y` and the binary replaces itself, taking effect on the next start. You can also update explicitly any time:

```bash
sleeperagent update # or --check to only report
```

Disable the startup check with `check = false` under `[update]` in config.toml, or `SLEEPERAGENT_NO_UPDATE_CHECK=1`.

**With the Go toolchain:**

```bash
Expand Down Expand Up @@ -121,6 +129,7 @@ On every platform, `run` from a real terminal drops you **straight into the live
| `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] [--no-profile]` | Copy this binary to a PATH directory. |
| `update [--check]` | Update to the latest GitHub release (checksum-verified); `--check` only reports. |
| `version` | Print the build version. |

### `run` flags
Expand Down
94 changes: 94 additions & 0 deletions cmd/sleeperagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package main

import (
"bufio"
"context"
"flag"
"fmt"
Expand Down Expand Up @@ -33,13 +34,18 @@ import (
"github.com/amanjaiman/sleeperagent/internal/statefile"
"github.com/amanjaiman/sleeperagent/internal/supervisor"
"github.com/amanjaiman/sleeperagent/internal/tmux"
"github.com/amanjaiman/sleeperagent/internal/update"
)

// version is overridden at build time via -ldflags "-X main.version=...".
var version = "dev"

func main() {
log.SetFlags(log.Ltime)
// A Windows self-update leaves the previous exe parked as "<exe>.old"
// (a running exe can't be overwritten); sweep it up now that we're the
// new binary.
update.CleanupOld()
if len(os.Args) < 2 {
usage()
os.Exit(2)
Expand All @@ -50,6 +56,8 @@ func main() {
err = runCmd(os.Args[2:])
case "attach-existing":
err = attachExistingCmd(os.Args[2:])
case "update":
err = updateCmd(os.Args[2:])
case "agents":
err = agentsCmd(os.Args[2:])
case "parse":
Expand Down Expand Up @@ -94,6 +102,7 @@ Usage:
sleeperagent detach --name NAME stop watching, keep session
sleeperagent stop --name NAME [--kill] stop watching (optionally kill)
sleeperagent rm --name NAME [--force] | --all remove a stale/ended instance record
sleeperagent update [--check] update to the latest release (--check: only report)
sleeperagent version print version

Run flags:
Expand Down Expand Up @@ -151,6 +160,7 @@ func runCmd(args []string) error {
if err != nil {
return err
}
maybeOfferUpdate(cfg)
ad, err := cfg.Adapter(*agent)
if err != nil {
return err
Expand Down Expand Up @@ -570,6 +580,89 @@ func watchSession(p watchParams) error {
return nil
}

// updateCmd checks the latest GitHub release and (unless --check) replaces the
// current executable with it, after verifying the download against the
// release's checksums.txt.
func updateCmd(args []string) error {
fs := flag.NewFlagSet("update", flag.ContinueOnError)
checkOnly := fs.Bool("check", false, "only report whether an update is available")
if err := fs.Parse(args); err != nil {
return err
}
cl := update.New()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
latest, err := cl.LatestVersion(ctx)
cancel()
if err != nil {
return fmt.Errorf("could not determine the latest release: %w", err)
}
if !update.Parseable(version) {
return fmt.Errorf("this build reports version %q (built from source?) — update with "+
"`go install github.com/amanjaiman/sleeperagent/cmd/sleeperagent@%s` or download a release binary",
version, latest)
}
update.RecordCheck(latest)
if !update.Newer(version, latest) {
fmt.Printf("sleeperagent %s is up to date (latest release: %s)\n", version, latest)
return nil
}
if *checkOnly {
fmt.Printf("update available: %s (you have %s). Run `sleeperagent update` to install it.\n", latest, version)
return nil
}
fmt.Printf("updating %s → %s ...\n", version, latest)
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := cl.Apply(ctx, latest); err != nil {
return err
}
fmt.Printf("updated to %s. Running instances keep their current version until restarted.\n", latest)
return nil
}

// maybeOfferUpdate is the Codex-style startup check: at most once a day, only
// in a real terminal, opt-out via config or env, and never blocking startup on
// a slow or offline network. Runs before the session launches so the prompt
// doesn't fight the agent for the terminal.
func maybeOfferUpdate(cfg config.Config) {
if !update.Parseable(version) || !cfg.UpdateCheckEnabled() || os.Getenv("SLEEPERAGENT_NO_UPDATE_CHECK") != "" {
return
}
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return
}
if !update.ShouldCheck(24 * time.Hour) {
return
}
cl := update.New()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
latest, err := cl.LatestVersion(ctx)
cancel()
// Record the attempt even on failure so an offline machine isn't re-stalled
// (up to the timeout) on every startup.
update.RecordCheck(latest)
if err != nil || !update.Newer(version, latest) {
return
}
fmt.Printf("sleeperagent %s is available (you have %s). Update now? [Y/n] ", latest, version)
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "", "y", "yes":
default:
return
}
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := cl.Apply(ctx, latest); err != nil {
log.Printf("update failed (continuing with %s): %v", version, err)
return
}
log.Printf("updated to %s — takes effect on the next start; this run continues on %s", latest, version)
}

// buildBuilder constructs the resume-prompt builder (static or local-LLM).
func buildBuilder(cfg config.Config, ad *adapter.Adapter, repromptSpec, resumeText string) (prompt.Builder, string, error) {
if repromptSpec == "" {
Expand Down Expand Up @@ -659,6 +752,7 @@ func attachExistingCmd(args []string) error {
if err != nil {
return err
}
maybeOfferUpdate(cfg)
ad, err := cfg.Adapter(*agent)
if err != nil {
return err
Expand Down
7 changes: 7 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ limit_patterns = [
inject_style = "text-enter" # text, then Enter
yolo_flag = "--dangerously-bypass-approvals-and-sandbox"

# Startup update check: on `run`/`attach-existing` from a real terminal,
# SleeperAgent asks GitHub (at most once a day) whether a newer release exists
# and offers to install it. Set check = false to disable, or set the
# SLEEPERAGENT_NO_UPDATE_CHECK env var. `sleeperagent update` always works.
[update]
check = true

# Local-LLM reprompt. Active only with `--reprompt ollama:<model>`; otherwise the
# static prompt is used. On any failure (server down, empty/over-long/denylisted
# output) SleeperAgent falls back to the static prompt.
Expand Down
16 changes: 16 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,26 @@ type RepromptConfig struct {
Denylist []string `toml:"denylist"`
}

// UpdateConfig configures the startup update check. Check is a pointer so an
// absent key keeps the default (enabled) while `check = false` disables it.
type UpdateConfig struct {
Check *bool `toml:"check"`
}

// Config is the whole SleeperAgent configuration.
type Config struct {
PollInterval Duration `toml:"poll_interval"`
ResetBuffer Duration `toml:"reset_buffer"`
MaxWait Duration `toml:"max_wait"`
Agents map[string]AgentConfig `toml:"agents"`
Reprompt RepromptConfig `toml:"reprompt"`
Update UpdateConfig `toml:"update"`
}

// UpdateCheckEnabled reports whether the startup update check is on (the
// default when the config doesn't say otherwise).
func (c Config) UpdateCheckEnabled() bool {
return c.Update.Check == nil || *c.Update.Check
}

// Default returns the built-in configuration, including the Claude Code adapter.
Expand Down Expand Up @@ -201,6 +214,9 @@ func overlay(base *Config, user Config) {
if len(user.Reprompt.Denylist) != 0 {
base.Reprompt.Denylist = user.Reprompt.Denylist
}
if user.Update.Check != nil {
base.Update.Check = user.Update.Check
}
}

// Adapter compiles the named agent's config into a ready-to-use adapter.
Expand Down
Loading
Loading