Documentation
¶
Overview ¶
Package session owns the lifecycle of the shell processes: one pty per session, a *screen.Screen (pkg/screen) rendering it and providing bounded scrollback, a drain goroutine per session and a Manager exposing New/Kill/List/Get.
It is deliberately independent from pkg/tasks: the TaskManager only owns display goroutines, never the shell processes themselves.
Implemented in phase 2 of ROADMAP.md.
Index ¶
- Constants
- type Manager
- func (m *Manager) Get(id string) (*Session, bool)
- func (m *Manager) Kill(id string) error
- func (m *Manager) List() []*Session
- func (m *Manager) New(name, shell string) (*Session, error)
- func (m *Manager) NewInDir(name, shell, cwd string) (*Session, error)
- func (m *Manager) NewWithOptions(opts Options) (*Session, error)
- func (m *Manager) Remove(id string) error
- func (m *Manager) Restart(id string) (*Session, error)
- func (m *Manager) Shutdown()
- func (m *Manager) StatsAll() map[string][]ProcStats
- type Options
- type ProcStats
- type RestartPolicy
- type Session
- func (s *Session) AgentName() string
- func (s *Session) AgentState() agent.State
- func (s *Session) ArmWatch(pattern string) error
- func (s *Session) Command() string
- func (s *Session) Done() <-chan struct{}
- func (s *Session) Env() []string
- func (s *Session) ExitCode() int
- func (s *Session) Group() string
- func (s *Session) Kill(timeout time.Duration) (err error)
- func (s *Session) LastWatchHit() (WatchHit, bool)
- func (s *Session) Name() string
- func (s *Session) Resize(cols, rows int) error
- func (s *Session) RestartAttempts() int
- func (s *Session) RuntimeWatchPattern() string
- func (s *Session) Screen() *screen.Screen
- func (s *Session) SetAgentState(state agent.State)
- func (s *Session) SetGroup(group string)
- func (s *Session) SetName(name string)
- func (s *Session) Stats() ([]ProcStats, error)
- func (s *Session) Status() Status
- func (s *Session) TurnDuration() (d time.Duration, ok bool)
- func (s *Session) WillAutoRestart() bool
- func (s *Session) Write(p []byte) (int, error)
- type Status
- type WatchHit
- type WatchSpec
Constants ¶
const ( // DefaultRestartBackoffBase/DefaultRestartBackoffMax bound the delay // before an automatic restart: it doubles each consecutive attempt, // starting at Base, capped at Max. There is deliberately no cap on the // number of attempts — the slowdown itself is the safeguard against an // instantly-failing command looping, not an attempt ceiling that would // eventually leave the session dead for good with no further action. DefaultRestartBackoffBase = 1 * time.Second DefaultRestartBackoffMax = 60 * time.Second // DefaultRestartSuccessDuration is how long a restarted run must stay up // before its consecutive-attempt count resets to zero. Applies to every // automatic restart uniformly, not just ones that followed a failure — // an always-policy session exiting cleanly in a fast loop still needs to // be throttled, or it spins with no backoff at all. DefaultRestartSuccessDuration = 10 * time.Second )
const DefaultKillTimeout = 2 * time.Second
DefaultKillTimeout is how long Kill waits for a session to die after SIGTERM before escalating to SIGKILL, and again after that before giving up.
Kept short rather than generous: on at least one real target platform (WSL2), SIGTERM delivery to a pty-owning process — by process group or by direct pid, same effect either way — becomes unreliable/delayed as soon as a second pty-owning session exists in the same process, even though it is near-instant with only one. SIGKILL always still lands, so a short timeout just bounds how long a "kill" keypress can visibly hang for, rather than trying to fix signal delivery itself (out of userspace's control).
const DefaultStopOnFailurePollInterval = 200 * time.Millisecond
DefaultStopOnFailurePollInterval is how often watchStopOnFailure polls a running session's Screen for its injected command's OSC 133 exit event. pkg/screen's OSC 133 state (osc133.go) is deliberately poll-only — there is no callback fired as the sequence is parsed — so a free-running ticker is the only way to react to a command failing while the shell underneath it stays alive.
const DefaultTerm = "xterm-256color"
DefaultTerm is the TERM value sessions are started with unless Manager.Term says otherwise: the bundled emulator (pkg/screen) implements 256-colour xterm, and announcing anything less makes shells and editors degrade for no reason.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Manager ¶
type Manager struct {
// KillTimeout is exported so tests can shrink it instead of waiting on a
// production-sized timeout.
KillTimeout time.Duration
// ScrollbackSize is the terminal emulator's scrollback size (pkg/config's
// ScrollbackSize), in lines. Zero means "use the emulator's own default"
// (vt.DefaultScrollbackSize) — NewManager's zero value is deliberately
// usable as-is by every existing test.
ScrollbackSize int
// Term is the TERM value sessions are started with (pkg/config's Term).
// Empty means DefaultTerm.
Term string
// Detector classifies a session's foreground process into an AI agent
// state (pkg/agent), consulted from each session's drain goroutine. Nil
// means "no manifests loaded" — every session then reports
// agent.StateNone, the same as a session running no known agent; this is
// NewManager's zero value so every existing test keeps working unchanged.
Detector *agent.Detector
// DefaultEnvFiles are .env-style files loaded, in order, for every
// session this Manager creates — after the per-session default "<cwd>/.env"
// and before a session's own Options.EnvFiles. pkg/app wires this to the
// repeatable --env-file flag, so it applies to sessions created
// interactively too, not just ones a project file declares.
DefaultEnvFiles []string
// DisableDefaultEnv turns off the automatic "<cwd>/.env" lookup for every
// session this Manager creates, unless a session's own
// Options.NoDefaultEnvFile overrides it back on. pkg/app wires this to
// --no-env-file.
DisableDefaultEnv bool
// ControlSocket is the agent control API's socket path (pkg/control),
// injected into every session as $LAZYSHELL_CONTROL_SOCK. Empty — the
// zero value, and what pkg/app leaves it at unless config.Control.Enabled
// is true — means the API is off: no variable is injected, and a session
// therefore has no way to find the socket even if one existed.
ControlSocket string
// RestartBackoffBase/RestartBackoffMax/RestartSuccessDuration tune
// restart.go's exponential backoff. Exported, like KillTimeout, so tests
// can shrink them instead of waiting on production-sized timers;
// NewManager sets all three to their Default* values.
RestartBackoffBase time.Duration
RestartBackoffMax time.Duration
RestartSuccessDuration time.Duration
// StopOnFailurePollInterval tunes watchStopOnFailure's poll rate.
// Exported, like KillTimeout and the Restart* fields above, so tests can
// shrink it instead of waiting on DefaultStopOnFailurePollInterval.
StopOnFailurePollInterval time.Duration
// contains filtered or unexported fields
}
Manager owns every session's lifecycle: creation, lookup, listing and killing. It is deliberately independent from any future TaskManager, which will only own display/reading goroutines, never the shell processes themselves.
func NewManager ¶
func NewManager() *Manager
NewManager returns an empty Manager, ready to create sessions.
func (*Manager) List ¶
List returns every session in creation order, including exited ones: they stay visible, the same way a stopped container stays listed in lazydocker.
func (*Manager) New ¶
New starts shell behind a pty, in the current working directory, and registers it under the given name. It is a thin wrapper around NewWithOptions for the common case.
func (*Manager) NewInDir ¶
NewInDir is New with an explicit working directory — the "session dans un cwd choisi" ergonomics feature.
func (*Manager) NewWithOptions ¶
NewWithOptions is the full session constructor. The session's drain goroutine is started before it returns, so no output is lost from the moment the shell is up.
func (*Manager) Remove ¶
Remove kills the session if it is still running, then drops it entirely from the manager — unlike Kill, the session no longer appears in List() afterwards and cannot be restarted. Used when the user wants a session gone from the panel rather than just stopped.
func (*Manager) Restart ¶
Restart re-creates an exited session with the same id and the Options it was originally started with — same name, shell, cwd, env and initial command — plus whichever group it currently belongs to, which may not be the one it started in. The session that just exited cannot be reused directly: killOnce means Kill (and the exit that already happened) can only ever run once on a given *Session*, so this spawns a fresh one and swaps it in under the same id, keeping the session's position in List().
func (*Manager) Shutdown ¶
func (m *Manager) Shutdown()
Shutdown kills every session and waits for all of them to fully terminate, so a caller (pkg/app on quit) knows it can exit the process without leaving orphaned children behind. There is no detach in the MVP: everything dies with lazyshell.
func (*Manager) StatsAll ¶
StatsAll samples every live session at once, keyed by session id. Sessions that cannot be sampled — exited, or whose process vanished — are simply absent from the result.
One call, not one per session: on darwin each sampleProcs is a `ps` spawn, so sampling eight sessions separately would mean eight processes every interval instead of one. This is what makes background sampling (pkg/gui keeps a history whether or not the resources tab is open) affordable.
type Options ¶
type Options struct {
// Name is the session's display name.
Name string
// Group is the session's initial group, "" for an ungrouped one. Only the
// starting value: the group can be reassigned afterwards through
// Session.SetGroup, and Restart carries that reassignment across rather
// than resetting to this one.
Group string
// Shell is the command started behind the pty.
Shell string
// Cwd is the shell's working directory. Empty means the process's own.
Cwd string
// Env adds to (and overrides within) the inherited environment. It has
// the final word — it wins over every .env file, default or explicit.
Env map[string]string
// EnvFiles are .env-style files loaded, in order, after the Manager's own
// DefaultEnvFiles: a later file overrides keys set by an earlier one, and
// Env always wins over all of them. Relative paths are resolved by the
// caller (pkg/config, against the project file's directory), not here.
EnvFiles []string
// NoDefaultEnvFile overrides the Manager's DisableDefaultEnv for this
// session only. Nil means "use the Manager's setting".
NoDefaultEnvFile *bool
// Command, when non-empty, is typed into the session once it is up — see
// NewWithOptions for why it is injected rather than exec'd.
Command string
// Watch declares this session's pattern watchers (watch.go), compiled
// once at creation. A pattern that fails to compile here (config.Validate
// already checked SessionSpec.Watch, so this is only reachable for a
// caller that builds Options by hand, e.g. a test) is dropped silently —
// same "gutter hint, never a hard dependency" rule as agent detection.
Watch []WatchSpec
// Restart is this session's automatic restart-on-exit policy. The Go zero
// value ("") is RestartNever, so every existing Options{} literal — every
// test, every session Options built without mentioning it — keeps
// meaning "never" with no explicit opt-out required. See restart.go.
Restart RestartPolicy
// StopOnFailure, combined with a non-empty Command, kills the session
// outright the moment that Command's own exit code (learned via the
// shell's OSC 133 integration, pkg/screen.Screen.LastCommandExit) is
// non-zero — instead of the default of leaving the shell running
// underneath (see newSession's doc comment on why Command is injected
// rather than exec'd). It only ever looks at the first command-exit
// event whose cycle actually ran a command (see stop_on_failure.go on
// why that is not simply the first event the incarnation's Screen ever
// reports): the session's shell keeps running afterwards, and a later
// command the user types by hand must never be mistaken for the one
// this option is about. The Go zero value (false) is today's existing behaviour,
// unchanged, so no existing Options{} literal needs updating. See
// stop_on_failure.go.
StopOnFailure bool
}
Options describes a session to create. It exists because the declarative project config (phase 6) needs two things the positional constructors cannot express — extra environment variables and a command to run on startup — without growing a fourth and fifth positional argument on every call site.
type ProcStats ¶
type ProcStats struct {
// PID is the process this sample is about, and Comm its bare name.
PID int
Comm string
// Foreground marks the sample taken for the pty's foreground process
// group leader rather than for the shell itself — the `claude` or `vim`
// the user is actually looking at. False for the shell's own sample.
Foreground bool
// CPUTime is the cumulative user+system time the process has consumed
// since it started. A percentage is the delta between two of these over
// the wall-clock time between them.
CPUTime time.Duration
// RSSBytes is the resident set size: physical memory currently held.
RSSBytes uint64
// Threads is the process's thread count, or 0 where the platform does not
// report one without cgo (darwin) — see ThreadsAvailable.
Threads int
ThreadsAvailable bool
// DiskRead/DiskWritten are cumulative bytes actually fetched from and sent
// to storage. DiskIOAvailable is false when the platform cannot report
// them at all (darwin, where they live behind libproc's proc_pid_rusage
// and therefore behind cgo) or refuses to (a hardened Linux kernel
// answering EACCES on /proc/<pid>/io). A false here means "we cannot
// know", never "zero bytes".
DiskRead, DiskWritten uint64
DiskIOAvailable bool
// SampledAt is when the reading was taken, so a caller computing a rate
// divides by the real elapsed time rather than by its own tick period.
SampledAt time.Time
}
ProcStats is one process's resource usage at one instant. Everything here is a *cumulative* or *instantaneous* reading, never a rate: a percentage needs two samples, and deciding how far apart they are is the caller's business, not this package's (see pkg/gui's perf tab, which keeps the previous sample in its render task's closure).
type RestartPolicy ¶
type RestartPolicy string
RestartPolicy is a session's automatic restart-on-exit policy. RestartNever is the empty string deliberately — Go's zero value, so every Options{} literal that never mentions Restart keeps meaning "never" with no explicit opt-out required.
const ( RestartNever RestartPolicy = "" RestartOnFailure RestartPolicy = "on-failure" RestartAlways RestartPolicy = "always" )
type Session ¶
type Session struct {
ID string
Cmd *exec.Cmd
Cwd string
CreatedAt time.Time
// contains filtered or unexported fields
}
Session owns one shell process behind a pty, together with the terminal emulator that renders its output. It keeps running independently of whether it is currently displayed: a drain goroutine, started at creation and alive for the life of the process, continuously feeds the emulator so no output is ever lost.
func (*Session) AgentName ¶ added in v1.12.0
AgentName is the detected agent's manifest name for this session's foreground process (e.g. "claude", "codex", "opencode") — "" if none matched yet, or if no Detector is wired. Independent of AgentState: it is derived purely from which manifest's process name matches, so it keeps being refreshed by evaluateAgentState even once the session is hookDriven, unlike AgentState itself. Safe to call from any goroutine.
func (*Session) AgentState ¶
AgentState is the last AI agent state detected for this session's foreground process (pkg/agent) — agent.StateNone for a session that is not running a known agent. Safe to call from any goroutine; see the mu doc comment on Session's fields.
func (*Session) ArmWatch ¶
ArmWatch sets (or, given "", clears) the session's single runtime-armed pattern — the 'v' keybinding's onSubmit, passed to showPrompt directly since the signatures already match. Unlike renameSession's empty-input no-op (a session's name must never be empty), an empty pattern here clears the runtime watch rather than doing nothing: "no runtime watch armed" is a normal, useful state, and blanking the prompt is the discoverable way to reach it. Config-declared watchers (setConfigWatchers) are untouched either way.
func (*Session) Command ¶ added in v1.14.0
Command reports the command this session was launched with, "" for a bare shell. Read-only: opts is fixed for the session's lifetime (Restart spawns a fresh process from the same opts rather than mutating this one), so no lock is needed.
func (*Session) Done ¶
func (s *Session) Done() <-chan struct{}
Done is closed once the session has fully terminated: process reaped, both copy goroutines returned. Waiting on it is how a caller confirms nothing of this session is left running.
func (*Session) Env ¶
Env is the environment the session's shell was started with — exactly what buildEnv produced (pkg/session/manager.go), in the same "KEY=value" shape as os.Environ.
This is the *launch-time* environment, not the shell's current one: an `export` typed at the prompt after startup is not reflected here. Reading the live environment of a running child is Linux-only in practice (/proc/<pid>/environ; darwin's KERN_PROCARGS2 is undocumented and permission-restricted), and a value that silently means something different on each OS would be worse than one that always means the same thing.
Cloned rather than returned directly: Cmd.Env is what the process was started with, and no caller has any business rewriting it.
func (*Session) ExitCode ¶
ExitCode is meaningful once Status returns StatusExited; -1 if the process was killed by a signal rather than exiting normally.
func (*Session) Group ¶
Group reports the session's group, "" for an ungrouped one. Grouping is a display property: it changes how the sessions panel arranges and addresses this session, never how its shell runs.
func (*Session) Kill ¶
Kill terminates the whole process group (so children the shell spawned, e.g. a foreground vim or sleep, die too, not just the shell itself) and waits up to timeout for it to be reaped. If it is still alive after that, it escalates to SIGKILL and closes the pty as a last resort, then waits up to timeout again.
A backgrounded job that gave itself its own process group via shell job control (`cmd &`) can survive this — a known, accepted gap, not solved here.
Always marks the session as explicitly killed — including when it is already exited, sitting in a restart backoff wait — which is what makes WillAutoRestart false from this point on: a deliberate stop always beats a restart policy.
func (*Session) LastWatchHit ¶
LastWatchHit is the most recent notify-eligible pattern match, polled by pkg/gui/notify.go's checkWatchNotifications on its own tick — the same poll-and-edge-detect shape as AgentState/LastCommandExit, not a push. ok is false until the first hit this session has ever produced.
func (*Session) Resize ¶
Resize propagates a new geometry to both the pty and the emulator, mirror of what cmd/spike-pty's resize does. cols/rows <= 0 are ignored: gocui reports a transient zero size during some layout passes.
func (*Session) RestartAttempts ¶
RestartAttempts is how many consecutive automatic restarts led to this particular incarnation — a display value, stamped once at construction by Manager.newSession. It does not update itself as this *Session*'s own backoff state changes; a new attempt always means a new *Session*.
func (*Session) RuntimeWatchPattern ¶
RuntimeWatchPattern is the session's current runtime-armed pattern, "" for none — used to prefill the 'v' prompt so re-arming shows what is already active instead of a blank box.
func (*Session) Screen ¶
Screen exposes the session's terminal emulator, for rendering. Screen itself is already safe for concurrent use.
func (*Session) SetAgentState ¶
SetAgentState pushes an authoritative agent state — the pkg/hook socket's only capability, called once per received event from its own connection-handling goroutine. Declarative only: it is the whole of what the *hook* channel can do to a Session, and all it can do is set this one value.
It used to be the only way anything outside lazyshell could affect a Session at all. That stopped being true with pkg/control, whose verbs can create, write to and kill sessions — behind config.Control.Enabled, off by default, and deliberately on another socket with another protocol precisely so this channel can stay open without carrying that weight. See docs/adr/0006-api-de-controle-par-les-agents.md.
Once called, evaluateAgentState's manifest-based guesswork stops running for the rest of this session's life (see hookDriven's field comment) — "autoritaire quand il rapporte" is read here as irreversible for the session, not an arbitration replayed on every event.
func (*Session) SetGroup ¶
SetGroup moves the session into a group, or out of every group when given "". Deliberately does not write back into s.opts: Manager.Restart reads old.opts with no lock held, a read that is only safe because opts is immutable after construction — Restart carries the live group across itself instead.
func (*Session) SetName ¶
SetName changes the session's display name — the "renommage de session" ergonomics feature. Purely cosmetic: it does not touch the running shell.
func (*Session) Stats ¶
Stats samples the session's resource usage: the shell process first, then the pty's foreground process group leader when that is a different process.
Deliberately *not* the whole process tree. What the user wants to see is the `claude` or `npm` in the foreground, not the `bash` that spawned it, and the foreground pgid is one ioctl away (foregroundPGID). Walking the tree would mean scanning all of /proc — or spawning a `ps -e` — once per session per refresh, a cost this feature does not justify.
An exited session has nothing to sample: /proc/<pid> is gone and the pid may already have been reused, so this errors rather than reporting a stranger's numbers.
func (*Session) TurnDuration ¶
TurnDuration is how long the current agent turn has been running, valid only while a turn is actually in progress: ok is false once the session leaves StateWorking, rather than freezing on a stale duration that would otherwise read as "still going" long after it finished. Backed by turnStartedAt, set only on the edge into StateWorking — see applyAgentState.
func (*Session) WillAutoRestart ¶
WillAutoRestart reports whether this session, having exited, has an automatic restart pending. A pure function of already-race-free state, so it can be re-evaluated at any point without depending on timing relative to Manager's own bookkeeping — see fireAutoRestart, which relies on that.
Deliberately not a Status value: every existing == StatusExited gate (ctl's wire format, restartGroup, restartSession, Manager.Restart) keeps working unchanged, and "R"/"W" get a free win as an immediate manual override of a pending backoff wait.
type WatchHit ¶
WatchHit is the latest notify-eligible pattern match, polled by pkg/gui the same way Screen.LastCommandExit is: Seq is what a caller compares against its own last-seen value to detect a fresh hit without missing one or firing twice for the same one.
type WatchSpec ¶
WatchSpec is one pattern watcher, as a caller of Options.Watch declares it. Deliberately the same two fields as pkg/config's own WatchSpec — kept as a separate type rather than importing pkg/config, the same "plain values only, no shared struct" boundary Options already draws for every other field ResolvedSession also carries.