// 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" "time" "github.com/hjbdev/patterm/internal/persist" "github.com/hjbdev/patterm/internal/vt" ) const childStopTimeout = 2 * time.Second // 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. // listeners is an atomic.Pointer to a frozen slice. Subscribe // copy-on-writes the slice; emit* paths use a single atomic Load. // This drops one mutex acquisition per PTY chunk on the hot path. listenersMu sync.Mutex listeners atomic.Pointer[[]ChildEventListener] // persistStore records top-level command entries to a per-project // JSON file so they can be re-spawned after patterm restarts. // Optional; nil means "no persistence" (used by unit tests). persistStore *persist.Store } // SetPersistStore attaches a process-persistence store. Future Spawn / // Close / Rename / SetAutoRestart calls on top-level command entries // will mirror the change into the store. func (s *Session) SetPersistStore(p *persist.Store) { s.mu.Lock() s.persistStore = p s.mu.Unlock() } // 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) // OnChildStateChanged fires when the idle-detection classifier // updates a child's IdleState. Listeners use this to repaint the // sidebar badge and to evaluate idle-aware timers. OnChildStateChanged(childID string, state IdleState) } 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() prev := s.listenersSnapshot() next := make([]ChildEventListener, 0, len(prev)+1) next = append(next, prev...) next = append(next, l) s.listeners.Store(&next) } // Unsubscribe removes a previously-registered listener. Safe to call // with a listener that wasn't registered (no-op). func (s *Session) Unsubscribe(l ChildEventListener) { s.listenersMu.Lock() defer s.listenersMu.Unlock() prev := s.listenersSnapshot() if len(prev) == 0 { return } next := make([]ChildEventListener, 0, len(prev)) for _, e := range prev { if e != l { next = append(next, e) } } s.listeners.Store(&next) } // listenersSnapshot returns the frozen listener slice. Safe to call // without the listeners mutex. func (s *Session) listenersSnapshot() []ChildEventListener { p := s.listeners.Load() if p == nil { return nil } return *p } func (s *Session) emitSpawn(c *Child) { for _, l := range s.listenersSnapshot() { l.OnChildSpawned(c) } } func (s *Session) emitExit(c *Child) { for _, l := range s.listenersSnapshot() { l.OnChildExited(c) } } // emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners // MUST NOT retain `chunk` past return — the slice is owned by the // pumpChild read buffer and is overwritten on the next read. func (s *Session) emitPTYOut(id string, chunk []byte) { for _, l := range s.listenersSnapshot() { l.OnPTYOut(id, chunk) } } func (s *Session) emitStateChanged(id string, state IdleState) { for _, l := range s.listenersSnapshot() { l.OnChildStateChanged(id, state) } } 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 // CleanupPaths are owned runtime files/dirs removed when the child exits // or is closed. They must be attached before the PTY starts so a // fast-exiting child cannot outrun cleanup registration. CleanupPaths []string // IdleDetection is the resolved per-preset idle classifier config. // Must be installed before the child is published to s.children so // the classifier goroutine never observes a nil/default config for // a preset that overrides it. IdleDetection *resolvedIdleDetection } // 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 } for _, path := range spec.CleanupPaths { c.AddCleanupPath(path) } // Install idle-detection BEFORE publishing to s.children — otherwise // the classifier goroutine could read c.idleDetection while the // launcher is still racing to set it. if spec.IdleDetection != nil { c.setIdleDetection(spec.IdleDetection) } runID, err := c.startPTY(cols, rows) if err != nil { c.cleanupOwnedPaths() return nil, err } s.mu.Lock() s.children[id] = c s.order = append(s.order, id) store := s.persistStore s.mu.Unlock() // Wire persistence callback BEFORE registering so SetName / // SetAutoRestart calls that race the listener still hit the store. if store != nil { c.setPersistFn(func(ch *Child) { s.persistEntry(ch) }) s.persistEntry(c) } s.emitSpawn(c) go s.pumpChild(c, runID) go s.reapChild(c, runID) return c, nil } // persistEntry writes (or refreshes) the persist record for c if it // qualifies — top-level command entries only. No-op when no store is // attached. func (s *Session) persistEntry(c *Child) { s.mu.Lock() store := s.persistStore s.mu.Unlock() if store == nil || !shouldPersist(c) { return } e := persist.Entry{ ID: c.ID, Name: c.DisplayName(), Argv: append([]string(nil), c.Argv...), WorkDir: c.WorkDir, PresetRef: c.PresetRef, AutoRestart: c.AutoRestart(), } if err := store.Save(e); err != nil { logf("persist save %s: %v", c.ID, err) } } func (s *Session) forgetPersisted(id string) { s.mu.Lock() store := s.persistStore s.mu.Unlock() if store == nil { return } if err := store.Remove(id); err != nil { logf("persist remove %s: %v", id, err) } } // shouldPersist gates which Child entries get mirrored into the // persist store. v1 only restores top-level command entries — agents // and terminals are ephemeral by design, and sub-agent-spawned // commands belong to their orchestrator's lifecycle. func shouldPersist(c *Child) bool { return c != nil && c.Kind == KindCommand && c.ParentID == "" } // 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 } runID, err := c.startPTY(cols, rows) if err != nil { return err } go s.pumpChild(c, runID) go s.reapChild(c, runID) 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) } // Only live entries can own runtime MCP config paths today. Keep the // reaper from cleaning those paths while restart swaps the PTY. c.restarting.Store(true) defer c.restarting.Store(false) if c.IsLive() { terminateAndWait(c, sig, childStopTimeout) } c.teardownPTY() runID, err := c.startPTY(cols, rows) if err != nil { return err } go s.pumpChild(c, runID) go s.reapChild(c, runID) 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() { terminateAndWait(c, sig, childStopTimeout) } c.teardownPTY() c.cleanupOwnedPaths() 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() s.forgetPersisted(id) 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, runID uint64) { pty := c.ptyForRun(runID) if pty == nil { return } // One PTY read buffer per pump goroutine. Consumers downstream // (em.Write is synchronous through CGO; recordWrite append-copies // into the ring; renderer.Render copies into its pending buffer) // all complete or copy before returning, so the buffer can be // reused without aliasing live data. See ChildEventListener.OnPTYOut // docstring — listeners must not retain `chunk`. buf := make([]byte, 64*1024) for { n, err := pty.Read(buf) if n > 0 { if !c.isCurrentRun(runID) { return } 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) } // OSC 0/2 title updates ride on the same byte stream as // the rest of the output. Polling the emulator after each // chunk is cheap on its own (one CGO call) but codex/ // ratatui sends so many small chunks that the per-chunk // CGO cost becomes measurable. Skip the Title poll when // the chunk doesn't carry an OSC start byte at all; the // title can only change on chunks that include one. if containsOSC(chunk) { if t, terr := em.Title(); terr == nil { c.recordTitle(t) } } } 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, runID uint64) { pty := c.ptyForRun(runID) if pty == nil { return } err := pty.Wait() if !c.isCurrentRun(runID) || c.restarting.Load() { return } c.markExited(err) logf("child %s exited (err=%v)", c.ID, err) s.emitExit(c) s.killDescendantsOf(c.ID) if !c.restarting.Load() { c.cleanupOwnedPaths() } // Terminals are ephemeral: unlike command entries (kept around for // restart_process) and agents (which the user clears via close_process // once they're done with the corpse), an exited terminal has nothing // useful left to do. Drop it from the session so it disappears from // the Processes sidebar / switch list immediately. if c.Kind == KindTerminal && !c.restarting.Load() { c.teardownPTY() s.mu.Lock() delete(s.children, c.ID) for i, oid := range s.order { if oid == c.ID { s.order = append(s.order[:i], s.order[i+1:]...) break } } s.mu.Unlock() } } // killDescendantsOf terminates every still-live direct child of // parentID. SPEC §2: closing the patterm process tears down every // child it spawned; the same rule applies in-session — when an // orchestrator dies (natural exit, user Ctrl-C, MCP close, anything // that makes its PTY EOF), the agents/commands/terminals it spawned // must die with it. We only signal direct children here: each // descendant's own reapChild will fire and recurse, so the cascade // flows through arbitrary depth without us walking the tree. func (s *Session) killDescendantsOf(parentID string) { if parentID == "" { return } s.mu.Lock() var live []*Child for _, c := range s.children { if c.ParentID == parentID && c.IsLive() { live = append(live, c) } } s.mu.Unlock() if len(live) == 0 { return } for _, c := range live { _ = c.signal(syscall.SIGTERM) } waitForAllStopped(live, childStopTimeout) for _, c := range live { if c.IsLive() { _ = c.signal(syscall.SIGKILL) } } waitForAllStopped(live, childStopTimeout) } func waitForAllStopped(children []*Child, timeout time.Duration) bool { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { anyLive := false for _, c := range children { if c.IsLive() { anyLive = true break } } if !anyLive { return true } time.Sleep(20 * time.Millisecond) } return false } func terminateAndWait(c *Child, sig syscall.Signal, timeout time.Duration) { if sig == 0 { sig = syscall.SIGTERM } _ = c.signal(sig) deadline := time.Now().Add(timeout) for c.IsLive() && time.Now().Before(deadline) { time.Sleep(20 * time.Millisecond) } if !c.IsLive() { return } _ = c.signal(syscall.SIGKILL) deadline = time.Now().Add(timeout) for c.IsLive() && time.Now().Before(deadline) { time.Sleep(20 * time.Millisecond) } } // 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() c.cleanupOwnedPaths() } } // containsOSC reports whether chunk holds a sequence that could begin // an OSC. OSC starts as ESC ] (0x1b 0x5d) or the bare C1 ] (0x9d), // so a chunk without either cannot have changed the emulator's OSC // title state. Used to short-circuit the per-chunk Title() poll from // pumpChild, which otherwise pays a CGO call for every chunk even // when codex/ratatui is just emitting SGR-styled output. func containsOSC(chunk []byte) bool { for i, b := range chunk { if b == 0x9d { return true } if b == 0x1b && i+1 < len(chunk) && chunk[i+1] == ']' { return true } } return false } 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...) }