Files
patterm/internal/app/session.go

493 lines
12 KiB
Go

// Package app is patterm's single foreground process. It owns the TUI,
// every PTY, every emulator, the in-process MCP server, and the
// scratchpad/preset state.
//
// There is no daemon, no detach, no socket-based client/daemon split
// (SPEC §2). One process owns everything; closing the terminal window
// ends the session and tears down every child.
package app
import (
"errors"
"fmt"
"os"
"sync"
"syscall"
"time"
"github.com/hjbdev/patterm/internal/vt"
)
// Session is the in-memory state for the running patterm process.
// In SPEC §4 terms each top-level tab is a session; v1 ships with a
// single implicit session and reserves room to grow.
type Session struct {
projectDir string
projectKey string
mu sync.Mutex
children map[string]*Child
order []string
// nameSeq tracks the default-name counter per kind (agent-1,
// command-2, terminal-3, …). Reset is a non-goal: counters are
// monotonic across the session lifetime.
nameSeq map[ChildKind]int
// listeners is the set of UI listeners that want to hear about child
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
listenersMu sync.Mutex
listeners []ChildEventListener
}
// ChildEventListener is implemented by the TUI to react to lifecycle
// events without polling.
type ChildEventListener interface {
OnChildSpawned(*Child)
OnChildExited(*Child)
// OnPTYOut is called for every chunk the child writes to its PTY.
// Only the focused-child chunk should reach the screen — the TUI
// filters by id.
OnPTYOut(childID string, chunk []byte)
}
func NewSession(projectDir, projectKey string) *Session {
return &Session{
projectDir: projectDir,
projectKey: projectKey,
children: make(map[string]*Child),
nameSeq: make(map[ChildKind]int),
}
}
func (s *Session) Subscribe(l ChildEventListener) {
s.listenersMu.Lock()
defer s.listenersMu.Unlock()
s.listeners = append(s.listeners, l)
}
func (s *Session) emitSpawn(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnChildSpawned(c)
}
}
func (s *Session) emitExit(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnChildExited(c)
}
}
func (s *Session) emitPTYOut(id string, chunk []byte) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnPTYOut(id, chunk)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
// detect it and degrade. The MCP socket is per-PID and lives under
// $XDG_RUNTIME_DIR — see internal/mcp.
env = append(env,
"PATTERM=1",
"PATTERM_PROJECT_KEY="+s.projectKey,
"PATTERM_PROJECT_DIR="+s.projectDir,
)
return env
}
// SpawnSpec is the argument record for Session.Spawn — the new
// argv-shaped spawn API matching SPEC §7 spawn_process.
type SpawnSpec struct {
Kind ChildKind
Argv []string
Env []string
WorkDir string
Name string
ParentID string
PresetRef string
Identity string // pre-minted; otherwise the constructor mints one for agents
}
// Spawn creates a new entry and starts its PTY. For Kind = command the
// entry remains in the session after PTY exit (it can be Restart'd).
// For agent/terminal the entry's lifetime equals the PTY's: reapChild
// fires emitExit and the entry stays in `exited` status until the
// caller `close_process`'s it.
func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
if len(spec.Argv) == 0 {
return nil, errors.New("session.Spawn: empty argv")
}
if spec.Env == nil {
spec.Env = s.ChildEnv()
}
s.mu.Lock()
id := s.mintUniqueIDLocked()
s.nameSeq[spec.Kind]++
if spec.Name == "" {
spec.Name = fmt.Sprintf("%s-%d", spec.Kind, s.nameSeq[spec.Kind])
}
s.mu.Unlock()
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
if spec.Identity != "" {
c.Identity = spec.Identity
}
if err := c.startPTY(cols, rows); err != nil {
return nil, err
}
s.mu.Lock()
s.children[id] = c
s.order = append(s.order, id)
s.mu.Unlock()
s.emitSpawn(c)
go s.pumpChild(c)
go s.reapChild(c)
return c, nil
}
// AddCommandEntry registers a command entry without starting it. Used
// by spawn_process(kind: command) when SPEC §7 needs the entry to exist
// in `stopped` state first (we always start it after; the indirection
// is here so future versions can support deferred starts).
func (s *Session) AddCommandEntry(spec SpawnSpec) *Child {
s.mu.Lock()
id := s.mintUniqueIDLocked()
s.nameSeq[spec.Kind]++
if spec.Name == "" {
spec.Name = fmt.Sprintf("%s-%d", spec.Kind, s.nameSeq[spec.Kind])
}
if spec.Env == nil {
spec.Env = s.ChildEnv()
}
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
s.children[id] = c
s.order = append(s.order, id)
s.mu.Unlock()
s.emitSpawn(c)
return c
}
// Start (re)attaches a PTY to an entry that is currently stopped or
// exited. Errors if the entry is already live.
func (s *Session) Start(id string, cols, rows uint16) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such process %q", id)
}
if c.IsLive() {
return nil // SPEC §7 start_process is a no-op on a running entry
}
if err := c.startPTY(cols, rows); err != nil {
return err
}
go s.pumpChild(c)
go s.reapChild(c)
return nil
}
// Restart stops the entry (if live) then starts it again with the same
// argv/env/workdir. Per SPEC §7: valid for command entries; valid for
// agent/terminal only while their PTY is still live.
func (s *Session) Restart(id string, sig syscall.Signal, cols, rows uint16) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such process %q", id)
}
if c.Kind != KindCommand && !c.IsLive() {
return fmt.Errorf("restart: %s entries can only be restarted while live", c.Kind)
}
if c.IsLive() {
if sig == 0 {
sig = syscall.SIGTERM
}
_ = c.signal(sig)
// Wait briefly for the reaper to mark exited. We don't need
// strict synchronization — the reaper will run regardless; we
// just want startPTY to land after teardown.
deadline := time.Now().Add(2 * time.Second)
for c.IsLive() && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if c.IsLive() {
// Force.
_ = c.signal(syscall.SIGKILL)
for c.IsLive() {
time.Sleep(20 * time.Millisecond)
}
}
}
c.teardownPTY()
if err := c.startPTY(cols, rows); err != nil {
return err
}
go s.pumpChild(c)
go s.reapChild(c)
return nil
}
// Close removes an entry from the session entirely. If still live,
// stops it first. SPEC §7 close_process.
func (s *Session) Close(id string, sig syscall.Signal) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such process %q", id)
}
if c.IsLive() {
if sig == 0 {
sig = syscall.SIGTERM
}
_ = c.signal(sig)
deadline := time.Now().Add(2 * time.Second)
for c.IsLive() && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if c.IsLive() {
_ = c.signal(syscall.SIGKILL)
for c.IsLive() {
time.Sleep(20 * time.Millisecond)
}
}
}
c.teardownPTY()
s.mu.Lock()
delete(s.children, id)
for i, oid := range s.order {
if oid == id {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
s.mu.Unlock()
return nil
}
// mintUniqueIDLocked mints an opaque process_id (SPEC §7) and retries
// if it collides with an existing entry. Caller holds s.mu.
func (s *Session) mintUniqueIDLocked() string {
for {
id := mintProcessID()
if _, exists := s.children[id]; !exists {
return id
}
}
}
func (s *Session) pumpChild(c *Child) {
buf := make([]byte, 64*1024)
for {
pty := c.PTY()
if pty == nil {
return
}
n, err := pty.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
if em := c.Emulator(); em != nil {
if _, werr := em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr)
}
}
c.recordWrite(chunk)
s.emitPTYOut(c.ID, chunk)
}
if err != nil {
if !errors.Is(err, syscall.EIO) && !errors.Is(err, os.ErrClosed) {
logf("pty read (child %s): %v", c.ID, err)
}
return
}
}
}
func (s *Session) reapChild(c *Child) {
pty := c.PTY()
if pty == nil {
return
}
err := pty.Wait()
c.markExited(err)
logf("child %s exited (err=%v)", c.ID, err)
s.emitExit(c)
}
// Children returns a snapshot of children in spawn order.
func (s *Session) Children() []*Child {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*Child, 0, len(s.order))
for _, id := range s.order {
if c, ok := s.children[id]; ok {
out = append(out, c)
}
}
return out
}
// FindChild looks up a child by id; returns nil if not present.
func (s *Session) FindChild(id string) *Child {
s.mu.Lock()
defer s.mu.Unlock()
return s.children[id]
}
// FindChildByIdentity finds the child whose Identity matches token.
// Used by MCP to bind a mcp-stdio greeting to its caller. Returns nil
// if no match.
func (s *Session) FindChildByIdentity(token string) *Child {
if token == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.children {
if c.Identity == token {
return c
}
}
return nil
}
// Kill sends a signal (default SIGTERM) to a child by id.
func (s *Session) Kill(id string, sig syscall.Signal) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such child %q", id)
}
if sig == 0 {
sig = syscall.SIGTERM
}
return c.signal(sig)
}
// WriteInput pipes bytes to a child's PTY stdin.
func (s *Session) WriteInput(id string, b []byte) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such child %q", id)
}
if c.Status() != StatusRunning {
return fmt.Errorf("child %q is %s", id, c.Status())
}
pty := c.PTY()
if pty == nil {
return fmt.Errorf("child %q has no pty", id)
}
_, err := pty.Write(b)
return err
}
// ResizeAll updates every child's PTY + emulator to the same cell grid.
// SPEC §5 says one viewport, no multi-client resize negotiation.
func (s *Session) ResizeAll(cols, rows uint16) {
if cols == 0 || rows == 0 {
return
}
s.mu.Lock()
cs := make([]*Child, 0, len(s.children))
for _, c := range s.children {
cs = append(cs, c)
}
s.mu.Unlock()
for _, c := range cs {
if pty := c.PTY(); pty != nil {
_ = pty.Resize(cols, rows)
}
if em := c.Emulator(); em != nil {
_ = em.Resize(cols, rows)
}
}
}
// SerializeChild returns the VT bytes that reproduce the child's
// current screen state. Used to repaint a child after the user switches
// focus or closes the palette.
func (s *Session) SerializeChild(id string) ([]byte, error) {
c := s.FindChild(id)
if c == nil {
return nil, fmt.Errorf("no such child %q", id)
}
em := c.Emulator()
if em == nil {
return nil, fmt.Errorf("child %q has no emulator", id)
}
return em.SerializeVT()
}
func (s *Session) StyledSnapshotChild(id string) ([]byte, error) {
c := s.FindChild(id)
if c == nil {
return nil, fmt.Errorf("no such child %q", id)
}
em := c.Emulator()
if em == nil {
return nil, fmt.Errorf("child %q has no emulator", id)
}
return em.StyledScreenVT()
}
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
c := s.FindChild(id)
if c == nil {
return "", vt.CursorState{}, fmt.Errorf("no such child %q", id)
}
em := c.Emulator()
if em == nil {
return "", vt.CursorState{}, fmt.Errorf("child %q has no emulator", id)
}
text, err := em.ScreenText()
if err != nil {
return "", vt.CursorState{}, err
}
cursor, err := em.Cursor()
if err != nil {
return "", vt.CursorState{}, err
}
return text, cursor, nil
}
// Shutdown kills every child and waits briefly for them to drain.
// Called on Ctrl-D / SIGTERM / SIGHUP. SPEC §2 step 4.
func (s *Session) Shutdown() {
s.mu.Lock()
cs := make([]*Child, 0, len(s.children))
for _, c := range s.children {
cs = append(cs, c)
}
s.mu.Unlock()
for _, c := range cs {
_ = c.signal(syscall.SIGTERM)
}
// Close emulators and PTY masters. The reaper goroutines will fire
// emitExit as Wait() returns.
for _, c := range cs {
c.teardownPTY()
}
}
func logf(format string, args ...any) {
if os.Getenv("PATTERM_DEBUG_LOG") == "" {
return
}
f, err := os.OpenFile(os.Getenv("PATTERM_DEBUG_LOG"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "patterm: "+format+"\n", args...)
}