Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
039f8b7
feat(bake): prebuild the repo into session images
kerem-kaynak Sep 26, 2026
4af81e3
feat!: add a shared api layer, speed profiles and a redesigned app
kerem-kaynak Sep 26, 2026
2b01f0c
feat(supervisor): restore the tmux layout when a parked session wakes
kerem-kaynak Sep 26, 2026
70d4db3
style(payload): wrap the freshen doc comment
kerem-kaynak Sep 26, 2026
7c581f3
docs: describe ready sessions, speed profiles, reminders and the app
kerem-kaynak Sep 26, 2026
fededd5
perf(aws): run independent pre-launch lookups concurrently
kerem-kaynak Sep 26, 2026
83fbe30
fix(bake): wait out slow image snapshots instead of the cli's ten min…
kerem-kaynak Sep 26, 2026
d273ecb
fix(cli): say just baked for a fresh image
kerem-kaynak Sep 26, 2026
438deac
fix(supervisor): restore claude panes as claude, even with nothing to…
kerem-kaynak Sep 26, 2026
d906168
feat: bring built environments up with .pier/start.sh, including on wake
kerem-kaynak Sep 26, 2026
f347231
fix(payload): rewrite the shell rc before tmux starts, not only in cl…
kerem-kaynak Sep 26, 2026
7fc353a
refactor!: drop start.sh; setup runs once and wakes restart containers
kerem-kaynak Sep 26, 2026
94ea3b5
fix(bootstrap): restart the supervisor so a baked image's older one c…
kerem-kaynak Sep 26, 2026
b80c46d
refactor!: bring back pier pool, in the CLI and the app
kerem-kaynak Sep 28, 2026
2ef4b6b
fix: steady session states, and claims read starting until usable
kerem-kaynak Sep 28, 2026
9a62276
docs: show the steady states in the README examples
kerem-kaynak Sep 28, 2026
bd74fa6
fix: faster first load, attach that stays in the app, no phantom atta…
kerem-kaynak Sep 28, 2026
5ef63f7
fix(aws): describe the tunnel fallback without naming a product
kerem-kaynak Sep 28, 2026
87bb380
feat(settings): pick the app's accent color
kerem-kaynak Sep 28, 2026
17f1803
fix(attach): show a connecting screen during the handover to ssh
kerem-kaynak Sep 28, 2026
b1724d5
style(settings): lighten the violet accent
kerem-kaynak Sep 28, 2026
6d09e7d
feat(attach)!: the session uses your browser while you're attached
kerem-kaynak Sep 28, 2026
db8dac1
style: gofmt
kerem-kaynak Sep 28, 2026
5541483
docs: settle the README and SPEC on this PR's behavior
kerem-kaynak Sep 28, 2026
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
251 changes: 173 additions & 78 deletions README.md

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions cmd/pier-supervisor/containers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"time"
)

// Parking is a shutdown, and a shutdown stops every container. A laptop gets
// its stack back after a reboot because the containers still exist and come
// back up; a woken session gets the same here: the supervisor keeps the list
// of containers that were running (with the tmux snapshot, and once more
// right before it parks), and the first supervisor of each boot starts those
// exact containers again — no compose file, no repo script, nothing to
// configure. Setup never re-runs: the containers, their volumes and their
// data are all still on the disk.

// containersFile holds the IDs of the containers running at the last
// snapshot. Kept outside ~/.pier/tmux: a claim discards the saved tmux
// layout, but its pooled session's containers are exactly what it wants back.
func containersFile(home string) string { return filepath.Join(home, ".pier", "containers.json") }

// containersRestarted marks, per boot (/run is tmpfs), that this boot's
// restart already ran, so a supervisor restart doesn't repeat it.
var containersRestarted = "/run/pier/containers-restarted"

// restartedThisBoot is set once the boot's restart has run (or there was
// nothing to restart). Until then snapshots leave the saved list alone: a
// snapshot taken while docker is still starting would record nothing and
// lose the list the restart is about to use.
var restartedThisBoot atomic.Bool

// docker runs the docker CLI; a var so tests can stand in a daemon.
var docker = func(args ...string) ([]byte, error) {
return exec.Command("docker", args...).Output()
}

// snapshotContainers records the running containers. A docker that doesn't
// answer leaves the previous list in place.
func snapshotContainers(home string) {
if !restartedThisBoot.Load() {
return
}
out, err := docker("ps", "-q", "--no-trunc")
if err != nil {
return
}
ids := strings.Fields(string(out))
if ids == nil {
ids = []string{}
}
b, _ := json.Marshal(ids)
_ = os.MkdirAll(filepath.Dir(containersFile(home)), 0o700)
_ = writeIfChanged(containersFile(home), b)
}

// restartContainers starts the containers that were running when the
// session last parked. It waits for the docker daemon (it starts alongside
// the supervisor at boot) for up to wait, then starts them in one call —
// docker orders nothing, but compose healthchecks and restart policies still
// apply to each container as it comes up. Returns a line for the journal.
func restartContainers(home string, wait time.Duration) string {
defer restartedThisBoot.Store(true)
if _, err := os.Stat(containersRestarted); err == nil {
return ""
}
defer func() {
_ = os.MkdirAll(filepath.Dir(containersRestarted), 0o755)
_ = os.WriteFile(containersRestarted, nil, 0o644)
}()
b, err := os.ReadFile(containersFile(home))
if err != nil {
return ""
}
var ids []string
if json.Unmarshal(b, &ids) != nil || len(ids) == 0 {
return ""
}
deadline := time.Now().Add(wait)
for {
if _, err := docker("info", "--format", "{{.ID}}"); err == nil {
break
}
if time.Now().After(deadline) {
return fmt.Sprintf("containers: docker didn't answer within %s — %d container(s) not restarted", wait, len(ids))
}
time.Sleep(time.Second)
}
// Containers removed since the snapshot fail individually; docker still
// starts the rest.
out, err := docker(append([]string{"start"}, ids...)...)
started := len(strings.Fields(string(out)))
if err != nil {
return fmt.Sprintf("containers: restarted %d of %d (%v)", started, len(ids), err)
}
return fmt.Sprintf("containers: restarted %d that were running before the park", started)
}
33 changes: 33 additions & 0 deletions cmd/pier-supervisor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
// runaway brake). Config is re-read every tick so `pier keep` edits apply
// live. State beacon: /run/pier/status.json (read by ls/TUI via ssh) — also
// carries the listening TCP ports so `pier proxy` knows what to mirror.
//
// Every ~30s, and once more right before parking, it also snapshots the tmux
// layout to ~/.pier/tmux; `pier-supervisor restore` rebuilds it on the next
// boot (tmux.go).
package main

import (
Expand Down Expand Up @@ -67,9 +71,30 @@ type status struct {
}

func main() {
if len(os.Args) > 1 && os.Args[1] == "restore" {
os.Exit(restoreMain())
}
if len(os.Args) > 1 && os.Args[1] == "open" {
os.Exit(openMain(os.Args[2:]))
}

// Bring back the containers the session had running before it parked,
// alongside the loop: docker may still be starting.
go func() {
home, err := os.UserHomeDir()
if err != nil {
restartedThisBoot.Store(true)
return
}
if msg := restartContainers(home, 3*time.Minute); msg != "" {
fmt.Println("pier-supervisor:", msg)
}
}()

idleSince := time.Now()
detachedBusySince := time.Time{}
last := status{}
lastSnap := time.Time{}

for {
idleTimeout, cap_ := readConf()
Expand Down Expand Up @@ -105,6 +130,11 @@ func main() {
last.Setup = setup
writeStatus(last)

if now.Sub(lastSnap) >= snapEvery && !restoring() {
snapshotNow()
lastSnap = now
}

if idleTimeout > 0 && !attached && !busy && now.Sub(idleSince) > idleTimeout {
park("idle for " + idleTimeout.String())
}
Expand Down Expand Up @@ -333,6 +363,9 @@ func writeStatus(s status) {

func park(reason string) {
writeStatus(status{State: "parking", Since: time.Now(), Reason: reason})
// The last word on the layout: up to 30s of window and scrollback changes
// since the previous snapshot would otherwise not come back on wake.
snapshotNow()
fmt.Println("pier-supervisor: parking:", reason)
_ = exec.Command("sudo", "shutdown", "-h", "now").Run()
os.Exit(0)
Expand Down
55 changes: 55 additions & 0 deletions cmd/pier-supervisor/open.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"bufio"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
)

// A session has no browser. Anything in it that opens a URL — an agent's
// OAuth login, `gh auth login`, a dev server's "open in browser" — goes
// through xdg-open or $BROWSER, both of which point here. While someone is
// attached, their attach connection forwards a unix socket back to their
// laptop, and the URL opens in their own browser; otherwise the URL is
// printed so it can be copied.

// openSocket is where an attach forwards the laptop's opener.
func openSocket(home string) string { return filepath.Join(home, ".pier", "open.sock") }

func openMain(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: xdg-open <url>")
return 1
}
url := args[0]
home, _ := os.UserHomeDir()
if err := sendOpen(openSocket(home), url); err != nil {
fmt.Fprintln(os.Stderr, "open this in your browser: "+url)
}
return 0
}

// sendOpen hands one URL to the laptop's opener and waits for its answer.
func sendOpen(sock, url string) error {
c, err := net.DialTimeout("unix", sock, 2*time.Second)
if err != nil {
return err
}
defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
if _, err := fmt.Fprintln(c, url); err != nil {
return err
}
reply, err := bufio.NewReader(c).ReadString('\n')
if err != nil {
return err
}
if strings.TrimSpace(reply) != "ok" {
return fmt.Errorf("opener: %s", strings.TrimSpace(reply))
}
return nil
}
Loading
Loading