session

package
v0.9.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Index

Constants

View Source
const DefaultRingSize = 2 << 20 // 2 MiB

DefaultRingSize is the default scrollback capacity per session.

View Source
const EphemeralRetention = 10 * time.Second

EphemeralRetention is ExitedRetention for a session marked ephemeral: long enough for the client that owns it to hear the exit, and no longer. It is not an expiry on a running scratch terminal — a dismissed scratch keeps running until its parent session ends (see Registry.Reap) — it only keeps exited ones from waiting out ten minutes hidden from every list.

View Source
const ExitedRetention = 10 * time.Minute

ExitedRetention is how long an exited session stays listable so its final output remains readable before the registry reaps it.

View Source
const HolderSocketName = "holder.sock"

HolderSocketName mirrors holder.SocketName. Declared here rather than imported because the dependency runs the other way: the holder package imports this one for the engine.

View Source
const SnapshotsDirName = "sessions"

SnapshotsDirName is the subdirectory of the flue config directory that carries shutdown snapshots from one daemon to the next.

Variables

View Source
var ErrHolderGone = errors.New("session: holder is gone")

ErrHolderGone reports a holder socket nobody answers: the holder process itself is not there. Callers deciding whether a holder directory may be swept need exactly this distinction — a holder that answered the dial but then failed the handshake is alive, and its session is not theirs to destroy.

View Source
var ErrNotFound = errors.New("session: not found")

ErrNotFound is what the registry answers for an id it does not hold. A client editing a session that has just exited and been reaped is ordinary rather than exceptional, so callers get a sentinel to turn into a polite answer instead of a message they would have to match on.

View Source
var ErrSessionClosed = errors.New("session closed")

Functions

func ClearSnapshot

func ClearSnapshot(dir, id string)

ClearSnapshot removes one snapshot's file, and is the only way a healthy snapshot leaves disk: its session is back, so the file has nothing left to say, and scrollback must not accumulate. Same guards as DeleteMeta, for the same reasons — idempotent, because the callers are cleanup paths, and refusing an empty dir or id rather than joining a relative path into whatever the working directory happens to hold.

func DeleteMeta

func DeleteMeta(dir, id string)

DeleteMeta removes one session's metadata file. It is idempotent, and it is called from paths that are already cleaning up after something else — a reap, a boot sweep — so a file that is not there is the outcome asked for rather than a failure to report.

An empty dir or id is refused rather than joined: with either missing the path would name something in the process's working directory, which is a stranger's file by definition.

func LoadMetas

func LoadMetas(dir string) map[string]Meta

LoadMetas reads every metadata file in dir, keyed by session id.

Reading is not consuming — snapshots leave disk too, through ClearSnapshot once their sessions are back, and the distinction is the point of the two files. A snapshot belongs to one revival; a name belongs to the session for as long as it exists, so it stays on disk until the session is reaped or is found to be gone.

A file that does not parse is deleted and skipped, on the same reasoning that governs a corrupt snapshot: corrupt state must never wedge a start, and a record nothing can read is not worth keeping around to fail again. A missing directory is simply no metadata.

func ReattachHolders added in v0.9.0

func ReattachHolders(r *Registry, root string) (reattached, swept int)

ReattachHolders walks the holders root a previous daemon left behind and rebuilds a registry entry for every holder still alive: dial the socket, hello, and the session is back under its old id with its ring intact — nothing was ever lost, because nothing lived in the old daemon.

A directory whose holder cannot be dialed is swept: the holder is gone (machine reboot, self-reap, a crash), so the socket answers nothing and the identity record describes a session that no longer runs. Sweeping is safe precisely because the revival state lives elsewhere — a holder that went down to SIGTERM wrote its snapshot into the snapshots directory, and the caller's revive pass picks that up after this one.

It runs before anything serves, so the registry lock discipline is not under pressure here; it still takes r.mu per insert like every other path.

func RecordReviveFailure

func RecordReviveFailure(dir string, snap Snapshot)

RecordReviveFailure charges one failed revival against a snapshot's file, leaving everything else in it intact. That is the whole point of surviving the failure: the identity, name, tags and scrollback are still on disk when the cause has cleared, and a later boot's LoadSnapshots hands the snapshot back for another try — until the count reaches maxReviveAttempts and the load sweeps it instead.

The write is best-effort, and a failure leaves the file exactly as it was: retried on every boot, never aging out. That corner errs deliberately on the side of the user's data — the boot still comes up, the retry costs a log line, and a directory this daemon cannot write to is not one it can bloat.

func SaveIdentity added in v0.9.0

func SaveIdentity(dir string, rec IdentityRecord) error

SaveIdentity writes dir/session.json, 0600 on a fresh inode like every other record in the config tree.

func SaveMeta

func SaveMeta(dir, id string, m Meta) error

SaveMeta writes one session's metadata to dir as <id>.meta.json.

Same treatment as a snapshot, for a weaker but real version of the same reason: what somebody called a session, and the tags they grouped it under, describe what they are working on. So 0600 in a 0700 directory, written to a fresh inode and renamed into place — a rename is atomic, which is what keeps a daemon that dies mid-write from leaving a half-written record for the next one to find.

func SaveSnapshots

func SaveSnapshots(dir string, snaps []Snapshot) error

SaveSnapshots writes each snapshot to dir as <id>.json. Scrollback is terminal output and can hold secrets, so the files get the token file's treatment: 0600 in a 0700 directory, written to a fresh inode and renamed into place. The first error is reported; the rest of the snapshots are still attempted, because one unwritable session must not cost the others their revival.

Types

type ChildConfig added in v0.9.0

type ChildConfig struct {
	ID string
	// Run is what execs; Argv is what Info reports. See registry.start for
	// why a login-shell detour is how a session starts, not what it is.
	Run  []string
	Argv []string
	Env  []string
	Cwd  string
	Cols uint16
	Rows uint16
	// RingSize of zero means DefaultRingSize, kept lenient here because a
	// wire-borne config omits it in the common case.
	RingSize int
	// Preload seeds the ring ahead of any live output — revival's scrollback.
	Preload []byte
	// Restore carries the fields a revival hands back; see registry.start.
	Restore Info
	// Group and Ephemeral mirror SpawnOpts, taking precedence over Restore
	// the same way a spawn's own naming does.
	Group     string
	Ephemeral bool
	// Clock defaults to time.Now. Tests substitute it; the wire never
	// carries it.
	Clock func() time.Time
}

ChildConfig is a spawn with nothing left to decide: the login shell, environment, working directory and sizes are all resolved. It exists so exactly one constructor starts children no matter which process the pty ends up in — the registry resolves and calls StartChild in-process, and a holder receives a resolved config over the wire and calls the same function.

func ResolveSpawn added in v0.9.0

func ResolveSpawn(opts SpawnOpts) ChildConfig

ResolveSpawn turns a caller's SpawnOpts into the resolved config StartChild wants, making every decision registry.start used to make inline: login shell, exec argv, environment, cwd fallback, default sizes and ring capacity. ID and Clock are the caller's to fill.

type Handle added in v0.9.0

type Handle interface {
	ID() string
	Info() Info
	ApplyMeta(MetaPatch) Info
	Tail(n int) (data []byte, cols, rows uint16)
	Write(p []byte) error
	Resize(cols, rows uint16) error
	Signal(sig os.Signal) error
	Subscribe(fromSeq uint64) *Sub
	Unsubscribe(*Sub)
	Close() error
}

Handle is a session as the daemon consumes one: everything conn.go and server.go need, and nothing about where the pty actually lives. *Session is the in-process implementation; a holder-backed session implements the same surface from the far side of a unix socket. The daemon depends on this interface so that the two are interchangeable per session, not per build.

type IdentityRecord added in v0.9.0

type IdentityRecord struct {
	V         int       `json:"v"`
	ID        string    `json:"id"`
	Cmd       []string  `json:"cmd"`
	Group     string    `json:"group,omitempty"`
	Ephemeral bool      `json:"ephemeral,omitempty"`
	CreatedAt time.Time `json:"createdAt"`
}

IdentityRecord is the daemon's half of a holder-backed session's state: the fields the daemon owns and the holder is never asked about. Written at spawn and rewritten on the one edit that changes it (an ephemeral scratch being kept); read at reattach.

func LoadIdentity added in v0.9.0

func LoadIdentity(dir string) (IdentityRecord, error)

LoadIdentity reads dir/session.json.

type Info

type Info struct {
	ID         string    `json:"id"`
	Title      string    `json:"title"`
	Name       string    `json:"name"`
	Tags       []string  `json:"tags"`
	Pinned     bool      `json:"pinned"`
	Cwd        string    `json:"cwd"`
	Cmd        []string  `json:"cmd"`
	State      string    `json:"state"` // "running" | "exited"
	ExitCode   int       `json:"exitCode"`
	Cols       uint16    `json:"cols"`
	Rows       uint16    `json:"rows"`
	CreatedAt  time.Time `json:"createdAt"`
	LastActive time.Time `json:"lastActive"`
	// Group and Ephemeral mirror SpawnOpts; see there. Both omitempty, so a
	// session that carries neither serialises exactly as it always has.
	Group     string `json:"group,omitempty"`
	Ephemeral bool   `json:"ephemeral,omitempty"`
}

Info is a snapshot of session state safe to serialise.

Title and Name are both labels and they are deliberately not the same field. Title is what the program running in the session says it is, scraped from OSC 0/2 and overwritten whenever it says something else; Name is what a human decided to call this session, and nothing running inside it may touch it. A UI shows the name when there is one and falls back to the title.

CreatedAt is the one timestamp that never moves. LastActive is the useful sort key right up until it isn't — a list ordered by it rearranges itself under the reader's cursor as output arrives — so a stable ordering needs a field that output cannot disturb.

type Meta

type Meta struct {
	V      int      `json:"v"` // 1
	Name   string   `json:"name"`
	Tags   []string `json:"tags"`
	Pinned bool     `json:"pinned"`
}

Meta is what a human decided about a session, kept apart from everything the session decided about itself.

It is a separate file from the snapshot, and written at a different moment, which is the whole of the difference between them. A snapshot is taken once, on a graceful shutdown, and carries the metadata out with it — so a clean restart already has everything and needs nothing from here. This file is written the instant an edit lands, which makes it the only copy of what a user typed for as long as the daemon is up.

That is a narrower claim than "it survives a crash", and the difference is worth stating plainly, because the two are easy to confuse. Nothing revives after a SIGKILL: no snapshot was written, so the next boot finds every record here describing a session that did not come back, and sweeps it. What the separation actually buys is independence from the snapshot's schedule and from its contents — this is applied after revival, so a snapshot taken by a daemon that predates these fields still comes back named, and an edit is durable from the moment it is made rather than from the next clean stop.

V is the file's version rather than the record's. Nothing reads it yet; it is here so that a later shape can be recognised instead of guessed at.

type MetaPatch

type MetaPatch struct {
	Name   *string
	Tags   *[]string
	Pinned *bool
	// Ephemeral is here for exactly one edit: a scratch terminal being kept.
	// Clearing the flag promotes it to an ordinary session — listable by the
	// client's rules and back on the ordinary exited retention.
	Ephemeral *bool
}

MetaPatch is a partial update to a session's human-owned metadata: a nil field means "leave this one alone".

Partial rather than whole-record on purpose. Two tabs open on the same session are the normal case, not the exotic one, and a client that had to send back every field would silently undo whatever the other one changed between its last read and this write. With a patch, an edit can only affect what it names.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry owns every session on this daemon.

func NewRegistry

func NewRegistry(clock func() time.Time) *Registry

func (*Registry) AdoptMetas

func (r *Registry) AdoptMetas(dir string)

AdoptMetas gives the sessions in this registry back the names and tags a previous daemon persisted, and sweeps what is left over.

It runs at boot, after revival, and the two halves are one pass because they are one question: for each record on disk, is the session it describes here? If it is, the metadata is applied — through ApplyMeta, so a hand-edited file's tags are normalised like anybody else's, and without re-writing the file that was just read. If it is not, the session did not survive the restart and its record is deleted, which is what keeps a crash from leaving the directory growing forever.

func (*Registry) CloseAll added in v0.9.0

func (r *Registry) CloseAll() int

CloseAll retires every session on this daemon, running and exited alike, and reports how many went. It is the registry half of `flue close --all`: a deliberate "end it all now", so unlike Reap it consults no retention window and asks no session whether it has exited.

The choreography is Reap's, for Reap's reasons. Victims are collected and removed from the map under r.mu and closed only after it is released, because Close signals a process group and waits for the session's supervisor to answer — done under r.mu, one stalled session would stall Get, List and Spawn for everyone. And each session's meta file goes with it: a registry that has finished with a session has nothing left for the record to describe.

func (*Registry) CloseByID added in v0.9.0

func (r *Registry) CloseByID(id string) error

CloseByID retires the one session it names, with CloseAll's semantics and ErrNotFound for an id the registry does not hold — the caller's cue to say "no such session" rather than to fail the rest of a batch.

The close error is discarded the way Reap discards it: the session left the registry the moment the map entry went, and nothing the caller could do with a failed group signal would put it back.

func (*Registry) Get

func (r *Registry) Get(id string) (Handle, bool)

func (*Registry) List

func (r *Registry) List() []Handle

func (*Registry) Reap

func (r *Registry) Reap()

Reap removes sessions that exited more than their retention ago — ExitedRetention ordinarily, EphemeralRetention for a scratch terminal — and closes the running ephemeral children of parents that have ended.

The second half is the whole of an ephemeral session's lifecycle: a scratch terminal is dismissed by detaching, never by closing, so the shell inside it runs on — a dev server started there keeps serving — until the session it was opened from exits or is reaped. This sweep is where that promise is kept. A parent that is merely exited (still listable in its retention window) already ends its scratch: the terminal the scratch belongs beside is over, and nothing can reopen it from there.

Victims are collected under r.mu and closed only after it has been released. Close signals a process group, waits for the session's supervisor to answer and closes a file descriptor; doing any of that while holding r.mu would turn a stall in one session into a stall of Get, List, Spawn and every other session too. The session calls made under r.mu, exitStatus and groupID, read fields under s.mu and return.

func (*Registry) Revive

func (r *Registry) Revive(snap Snapshot) (Handle, error)

Revive spawns a fresh login shell in a snapshot's place: the same id, so routes and bookmarks keep resolving, the same title, name, tags and pin, the same age, and the old scrollback preloaded ahead of a marker naming the restart. A cwd that no longer exists falls back to the home directory rather than failing the revival.

A snapshot written before a field existed carries that field's zero value, and the one place that matters is CreatedAt: start reads the zero time as "stamp this one now" rather than dating the session to the epoch. Everything else is honestly empty when it is empty.

func (*Registry) SetHolderSpawning added in v0.9.0

func (r *Registry) SetHolderSpawning(exe, root string)

SetHolderSpawning points the registry at the holder executable and the directory holder dirs live under; from then on every Spawn and Revive runs its session out-of-process. Empty either disables, which is the default and the whole of FLUE_NO_HOLDER.

func (*Registry) SetMetaDir

func (r *Registry) SetMetaDir(dir string, log *slog.Logger)

SetMetaDir says where session metadata is persisted, and with what logger.

An empty dir means nowhere, which is the default and the only way to say it: a registry nobody has pointed at a directory writes no files at all. A nil logger takes the default one, so a caller that has no logger of its own is not forced to invent a sink for a line it will probably never see.

Called once at startup, before anything is serving, but it takes r.mu anyway — the fields it writes are read on every edit, and "only at startup" is a property of today's caller rather than of this method.

func (*Registry) Snapshots

func (r *Registry) Snapshots() []Snapshot

Snapshots returns one Snapshot per running in-process session — the set a shutdown should carry over. Holder-backed sessions are not the daemon's to snapshot: their rings live with their holders, and each holder writes its own snapshot on its own SIGTERM.

func (*Registry) Spawn

func (r *Registry) Spawn(opts SpawnOpts) (Handle, error)

Spawn starts a new session. An empty Cmd runs the user's login shell as a login shell, inheriting the environment: flue is a terminal, and a sanitised environment would defeat the purpose.

func (*Registry) UpdateMeta

func (r *Registry) UpdateMeta(id string, p MetaPatch) (Info, error)

UpdateMeta patches one session's metadata by id and returns the resulting snapshot, ready to answer the request and to broadcast to everyone else watching. An id the registry does not hold is ErrNotFound.

Get releases r.mu before ApplyMeta takes s.mu, which keeps this on the right side of the one ordering rule between the two locks (see Session). It costs nothing worth having: the session could be reaped a moment after either lock, so holding both would not make the edit any less racy against the world, only more likely to stall it.

It flushes the result to disk when a meta directory is configured, before it returns: an edit a client has been told succeeded should not be able to vanish in the next crash.

type Remote added in v0.9.0

type Remote struct {
	// contains filtered or unexported fields
}

Remote is a holder-backed session: the same Handle surface *Session serves in-process, answered from a cache the holder's events keep fresh, with the writes crossing the socket. The daemon holds nothing here a restart can lose — a fresh daemon rebuilds a Remote from the socket and the identity record alone.

func DialRemote added in v0.9.0

func DialRemote(dir string, rec IdentityRecord, clock func() time.Time) (*Remote, error)

DialRemote rebuilds a Remote from a live holder's socket and the identity record beside it: the reattach path. The record's daemon-owned fields win over what the holder reports for them.

func SpawnRemote added in v0.9.0

func SpawnRemote(exe, dir string, cfg ChildConfig, clock func() time.Time) (*Remote, error)

SpawnRemote launches `exe _holder --dir dir`, waits for its socket, and spawns cfg into it. The holder is setsid'd away from the daemon so a daemon exit never takes it along; its stderr lands in dir/holder.log.

func (*Remote) ApplyMeta added in v0.9.0

func (r *Remote) ApplyMeta(p MetaPatch) Info

func (*Remote) Close added in v0.9.0

func (r *Remote) Close() error

Close retires the session: the holder kills the process group and exits, and the holder's directory — socket, identity record, log — goes with it.

func (*Remote) Dir added in v0.9.0

func (r *Remote) Dir() string

Dir is the holder directory this Remote speaks to.

func (*Remote) HolderPid added in v0.9.0

func (r *Remote) HolderPid() int

HolderPid is the holder process's pid when this Remote spawned it, and 0 for a Remote rebuilt by reattach.

func (*Remote) ID added in v0.9.0

func (r *Remote) ID() string

func (*Remote) Info added in v0.9.0

func (r *Remote) Info() Info

Info mirrors Session.Info: the cache, with the child's cwd re-read from the kernel while the session runs — the holder's pid makes that a local question the daemon can keep answering itself.

func (*Remote) Resize added in v0.9.0

func (r *Remote) Resize(cols, rows uint16) error

func (*Remote) Signal added in v0.9.0

func (r *Remote) Signal(sig os.Signal) error

func (*Remote) Subscribe added in v0.9.0

func (r *Remote) Subscribe(fromSeq uint64) *Sub

Subscribe opens one attach connection per subscriber. The Sub's contract is the engine's exactly: backlog plus channel is gap-free from StartSeq, and a subscriber that falls too far behind is dropped to reattach.

func (*Remote) Tail added in v0.9.0

func (r *Remote) Tail(n int) (data []byte, cols, rows uint16)

func (*Remote) Unsubscribe added in v0.9.0

func (r *Remote) Unsubscribe(sub *Sub)

func (*Remote) Write added in v0.9.0

func (r *Remote) Write(p []byte) error

type Ring

type Ring struct {
	// contains filtered or unexported fields
}

Ring is a fixed-capacity byte buffer that tracks a monotonic sequence number for every byte ever written. Bytes are addressed by absolute seq, so a reattaching client can ask for everything since the offset it last saw. Once the buffer is full, the oldest bytes are evicted and BaseSeq advances past them.

Ring is not safe for concurrent use; Session serialises access.

func NewRing

func NewRing(size int) *Ring

NewRing returns a Ring holding at most size bytes.

func (*Ring) BaseSeq

func (r *Ring) BaseSeq() uint64

BaseSeq is the seq of the oldest byte still retained.

func (*Ring) EndSeq

func (r *Ring) EndSeq() uint64

EndSeq is the seq just past the newest byte written.

func (*Ring) Since

func (r *Ring) Since(seq uint64) ([]byte, bool)

Since returns every retained byte at or after seq. ok is false when seq has already been evicted, which means the caller must send a full snapshot instead of a delta. A seq beyond EndSeq yields an empty slice and ok=true; that is a client that is simply up to date.

func (*Ring) Tail

func (r *Ring) Tail(n int) []byte

Tail returns the last n retained bytes, or everything retained when there are fewer than n. A non-positive n is an empty answer rather than an error: callers size it from a request, and "show me nothing" is a coherent ask.

It is Since expressed as a distance from the end rather than an absolute offset, which is what a reader who holds no seq at all needs — a preview wants "the last few kilobytes", and computing the offset for that at every call site means every call site has to know about BaseSeq eviction.

func (*Ring) Write

func (r *Ring) Write(p []byte)

Write appends p, evicting the oldest bytes if necessary.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session owns one PTY and its scrollback.

Three kinds of goroutine touch a Session: callers, through the exported methods; pump, which copies PTY output into the ring and fans it out to subscribers; and supervise, which owns the child process's lifecycle.

The locking rules, in full:

  • s.mu guards ring, title, subs, info, exitedAt and closed. It is never held across a syscall that can block — Resize holds it across TIOCSWINSZ, which cannot, and has to; see Resize — and callers release it before waiting on the supervisor, which takes s.mu itself when the child exits. That second rule is a courtesy rather than a correctness requirement: the supervisor takes s.mu with TryLock and retries rather than blocking, so a caller that holds the lock and waits still gets an answer.
  • Registry.mu is only ever acquired before s.mu, never after.

func StartChild added in v0.9.0

func StartChild(cfg ChildConfig) (*Session, error)

StartChild starts the child and its engine: pty, ring, pump, supervise. The session belongs to no registry until a caller adds it to one.

func (*Session) ApplyMeta

func (s *Session) ApplyMeta(p MetaPatch) Info

ApplyMeta applies a partial metadata update and returns the resulting snapshot — the same one a subsequent Info would report, so a caller can answer a client and broadcast the change without a second read.

Naming a session is not activity in it: LastActive is left alone, so tidying up a list of sessions cannot reorder the list being tidied.

func (*Session) Close

func (s *Session) Close() error

Close terminates the session's process group and releases the PTY.

"Process group", not "child": a shell that exits leaving `sleep 1000 &` behind has been reaped long before anyone calls Close, and the survivor is exactly what Close exists to clean up. It is still reachable, because the group pins its own id until the last member goes (see groupGone).

func (*Session) ID

func (s *Session) ID() string

func (*Session) Info

func (s *Session) Info() Info

Info returns a snapshot of the session's state, and is also where the child's cwd is refreshed — the kernel is the only party that knows where a `cd` left the shell, so every snapshot asks it.

The read happens before s.mu is taken. It needs nothing the lock guards — only the pid, which is immutable after spawn — so keeping it outside costs nothing and keeps the rule that s.mu is never held across a syscall intact without having to argue about whether this one can block.

A failed read keeps the previous value rather than blanking it: the common failure is a child that has exited, and "where it last was" remains the honest answer for as long as the session is listed. The store is gated on State == "running" for the same reason signalling stops at groupGone — after the reap the pid may be recycled, and a read that "succeeds" then may be describing a stranger's directory.

func (*Session) Pid added in v0.9.0

func (s *Session) Pid() int

Pid is the child's pid, immutable after spawn. A holder's hello carries it so the daemon can keep reading the child's cwd itself — processCwd needs the same uid, not the same parent.

func (*Session) Resize

func (s *Session) Resize(cols, rows uint16) error

Resize changes the PTY window size.

s.mu is held across the ioctl, and that is the whole point rather than an oversight. creack/pty's ioctl helper reaches the descriptor through os.File.Fd(), which hands over the raw number with no reference held on it — its refcounted sibling, ioctlNonblock, is marked "Unused" in that package. Write is safe releasing the lock first because os.File.Write *is* refcounted and answers ErrClosed; this is not. Released early, a concurrent Close — a client's `close`, or Registry.Reap on a session a tab is still watching — could close that descriptor between the check above and the ioctl, and the kernel hands the number straight back: measured, the next session's pty master takes it. TIOCSWINSZ would then land on an unrelated descriptor, plausibly another session's terminal.

Nothing can deadlock behind this. TIOCSWINSZ cannot block — the kernel writes the winsize and posts SIGWINCH — and the one goroutine that must never wait on s.mu, the supervisor, takes it with TryLock.

func (*Session) Seqs added in v0.9.0

func (s *Session) Seqs() (base, end uint64)

Seqs reports the ring's retained range: the oldest byte still held and the seq just past the newest. A holder's hello carries them so a daemon deciding where to attach knows what the ring can still answer for.

func (*Session) Signal

func (s *Session) Signal(sig os.Signal) error

Signal delivers a signal to the session's process group.

The signal is not sent from here. It is handed to the supervisor, which is the only goroutine allowed to signal or to reap; see supervise. s.mu is released before the handoff, so that this never waits on the supervisor while holding the lock the supervisor needs to record an exit.

func (*Session) Snapshot

func (s *Session) Snapshot() (Snapshot, bool)

Snapshot captures what a revival needs. ok is false for an exited or closed session — those end with the daemon rather than coming back — and for an ephemeral one: a scratch terminal's life is bound to its parent's process, and the parent's revival is a fresh shell the old scratch has no standing beside.

func (*Session) SnapshotForShutdown added in v0.9.0

func (s *Session) SnapshotForShutdown() (Snapshot, bool)

SnapshotForShutdown is Snapshot plus the agent-conversation hint — the record a process on its way down writes so the next boot can revive the session with a resume command. The agent-store lookup happens out here rather than inside Snapshot for one reason: it reads directories, and s.mu is never held across a syscall. Shared by the daemon's shutdown pass over in-process sessions and by a holder answering its own SIGTERM.

func (*Session) Subscribe

func (s *Session) Subscribe(fromSeq uint64) *Sub

Subscribe registers a subscriber for output at or after fromSeq. The backlog and the channel together are gap-free.

func (*Session) Tail

func (s *Session) Tail(n int) (data []byte, cols, rows uint16)

Tail returns the last n bytes of this session's scrollback, with the dimensions they were drawn at.

It is deliberately not a Subscribe: a caller that only wants to look does not want the delivery channel, the backlog bookkeeping or the eventual Unsubscribe that a real attachment costs, and a list that peeked at twenty rows by attaching to each of them would leave twenty subscribers on the session for as long as the daemon took to notice. Nothing about the session changes here — LastActive in particular is left alone, because reading a preview is not activity *in* the session, and moving the stamp would reshuffle the very list the preview is being drawn for.

func (*Session) Unsubscribe

func (s *Session) Unsubscribe(sub *Sub)

Unsubscribe removes a subscriber and closes its channel.

func (*Session) Write

func (s *Session) Write(p []byte) error

Write sends bytes to the PTY.

type Snapshot

type Snapshot struct {
	V      int      `json:"v"`
	ID     string   `json:"id"`
	Title  string   `json:"title"`
	Name   string   `json:"name"`
	Tags   []string `json:"tags"`
	Pinned bool     `json:"pinned"`
	// Group travels so a split survives a restart as a split: members are just
	// sessions, and this is the one fact that makes them members. omitempty
	// keeps the ungrouped snapshot byte-compatible with what earlier daemons
	// wrote and read. There is no Ephemeral beside it, because an ephemeral
	// session is never snapshotted at all — see Session.Snapshot.
	Group string `json:"group,omitempty"`
	Cwd   string `json:"cwd"`
	Cols  uint16 `json:"cols"`
	Rows  uint16 `json:"rows"`
	// The ring's retained bytes. encoding/json carries []byte as base64.
	Ring      []byte    `json:"ring"`
	CreatedAt time.Time `json:"createdAt"`
	SavedAt   time.Time `json:"savedAt"`
	// Agent and AgentSession name the coding-agent conversation this session
	// was most plausibly working in, when there was one — which tool
	// ("claude", "codex", "pi") and the reference its resume command wants: a
	// conversation id for Claude and Codex, a transcript path for Pi. See
	// claude.go and agents.go for how that is judged and how weak the
	// judgement is. Empty is the ordinary case: most sessions are not running
	// a coding agent, and nothing is printed for them. A reference, never a
	// command; the command is assembled at revival, so the wording lives in
	// one place.
	Agent        string `json:"agent,omitempty"`
	AgentSession string `json:"agentSession,omitempty"`
	// ClaudeSession is what AgentSession was called when Claude Code was the
	// only store read. Still written when the agent is claude — so a snapshot
	// laid down by this daemon keeps its hint across a downgrade — and still
	// read, so a snapshot laid down by an older daemon keeps its hint across
	// this upgrade. AgentSession wins when both are present.
	ClaudeSession string `json:"claudeSession,omitempty"`
	// Attempts counts the boots that have already tried and failed to revive
	// this snapshot. Every snapshot a shutdown writes carries zero;
	// RecordReviveFailure raises it, and LoadSnapshots sweeps the file once it
	// reaches maxReviveAttempts — see both for why the count exists at all.
	// omitempty keeps the ordinary snapshot, the one that revives on its first
	// try and is cleared, byte-compatible with what earlier daemons wrote and
	// read.
	Attempts int `json:"attempts,omitempty"`
}

Snapshot is a session worth reviving: identity, place, and scrollback.

It exists because the daemon is the session holder — its shells are its children and die with it — so a restart would otherwise be destructive. A snapshot brings the session back with its history and a fresh shell; the running process is the one thing it cannot carry.

It carries the human-owned metadata as well as the machine-owned identity, which makes a graceful restart self-contained: the sessions come back named, tagged and pinned without anything else on disk having to be consulted. The metadata files beside these are written on a different schedule and read after the revival, so they remain the answer for a snapshot that predates a field rather than a second source for one that carries it. See Meta.

CreatedAt travels because it is the one stamp a session must never lose. It is what a stable ordering is built on, and a restart that reset it would reshuffle every list in every client at once.

func LoadSnapshots

func LoadSnapshots(dir string) []Snapshot

LoadSnapshots reads every snapshot in dir. It sweeps what nothing can use and leaves the rest on disk: a snapshot file is cleared by ClearSnapshot, on the caller's word that its session actually came back — never merely for having been read. (This function's predecessor, LoadAndClearSnapshots, deleted each file as it was read, which meant a spawn failure at the caller — a dangling $SHELL, /dev/ptmx exhaustion — permanently destroyed the very session it was about to revive.)

Two kinds of file are swept here. One that does not parse, or parses to no id, is the old guarantee: corrupt state never wedges a startup, and a record nothing can read is not worth keeping around to fail again. One whose Attempts has reached maxReviveAttempts is the new one — see the constant for why sweeping it is what keeps preserving the others safe. A file that cannot be read at all is skipped in place rather than swept: nothing about it has been judged, and an unreadable file is the one this function has no license to destroy. A missing directory is simply no snapshots.

Metadata files share this directory and their names end in ".json" too, so the suffix alone does not identify a snapshot. They are skipped explicitly, and the check is still load-bearing: a metadata file is valid JSON with no snapshot id, so without it every name and tag on the machine would be swept as corrupt by the next start — silently, since a snapshot loader has no reason to complain about a file it merely failed to parse.

type SpawnOpts

type SpawnOpts struct {
	Cwd      string
	Cmd      []string // empty means the user's login shell
	Cols     uint16
	Rows     uint16
	RingSize int // zero means DefaultRingSize

	// Group is the id of the session this one is grouped under — the anchor a
	// client renders it beside as a split or a tab. It is metadata and nothing
	// more: the daemon never resolves it, never requires the anchor to exist,
	// and never treats members differently. Empty is every session spawned
	// before the field existed, and every session that stands alone.
	Group string
	// Ephemeral marks a session a client considers disposable — a scratch
	// terminal, spawned with Group naming the session it was opened from. Its
	// life is tied to that parent: dismissing the scratch UI merely detaches,
	// and the shell runs on until the parent session ends, at which point the
	// registry closes it (see Reap). Server-side it is otherwise only the
	// shorter exited retention; whether to hide it from a list is a client
	// decision.
	Ephemeral bool
}

SpawnOpts configures a new session.

type Sub

type Sub struct {
	Backlog   []byte
	StartSeq  uint64
	Truncated bool
	C         <-chan []byte
	// contains filtered or unexported fields
}

Sub is one subscriber's view of a session's output stream. Backlog plus everything delivered on C is exactly the byte stream from StartSeq onward. Truncated reports that the requested seq had already been evicted, so StartSeq is later than what was asked for and the client must reset its emulator before writing Backlog.

type TitleScanner

type TitleScanner struct {
	// contains filtered or unexported fields
}

TitleScanner extracts window titles from OSC 0 and OSC 2 sequences in a byte stream. It tolerates arbitrary chunk boundaries.

func NewTitleScanner

func NewTitleScanner() *TitleScanner

func (*TitleScanner) Feed

func (s *TitleScanner) Feed(p []byte) (string, bool)

Feed consumes p and reports the last complete title it contained.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL