// 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" "sync/atomic" "syscall" "github.com/harrybrwn/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 nextChildSeq atomic.Int64 // 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), } } 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 } // Spawn launches a new child with the given argv. kind is "agent" or // "process". parentID names the calling session/child for orchestrator // trees ("" for top-level). env is the full child environment; the // caller is responsible for adding preset-specific overrides. func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) { s.mu.Lock() id := fmt.Sprintf("c%d", s.nextChildSeq.Add(1)) if name == "" { name = fmt.Sprintf("%s-%s", kind, id) } s.mu.Unlock() if env == nil { env = s.ChildEnv() } c, err := newChild(id, name, kind, argv, env, cols, rows, parentID) if 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 } // spawnWithIdentity is like Spawn but lets the launcher pre-mint the // MCP identity so the config file can be written before the process // starts. func (s *Session) spawnWithIdentity(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, identity string) (*Child, error) { c, err := s.Spawn(name, kind, argv, env, cols, rows, parentID) if err != nil { return nil, err } c.Identity = identity return c, nil } func (s *Session) pumpChild(c *Child) { buf := make([]byte, 64*1024) for { n, err := c.pty.Read(buf) if n > 0 { chunk := make([]byte, n) copy(chunk, buf[:n]) if _, werr := c.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) { err := c.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()) } _, err := c.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 { _ = c.pty.Resize(cols, rows) _ = c.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) } return c.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) } text, err := c.em.ScreenText() if err != nil { return "", vt.CursorState{}, err } cursor, err := c.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.pty.Close() _ = c.em.Close() } } 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...) }