// 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) 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...) }