316 lines
7.8 KiB
Go
316 lines
7.8 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"
|
|
"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...)
|
|
}
|