package app import ( "bytes" "crypto/rand" "encoding/hex" "errors" "fmt" "os" "os/exec" "regexp" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" pkgpty "github.com/hjbdev/patterm/internal/pty" "github.com/hjbdev/patterm/internal/vt" ) // portRegex matches dev-server URLs of the form `http(s)://host:NNNN[/path]` // and reports the port. SPEC §7 get_process_ports is best-effort; we // stick to URL-form sightings because bare `:NNNN` produces too many // false positives (timestamps, exit codes, etc.). var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`) const ( agentInterPieceDelay = 15 * time.Millisecond agentSubmitSettleDelay = 100 * time.Millisecond ) type ChildStatus string const ( StatusStarting ChildStatus = "starting" StatusRunning ChildStatus = "running" StatusStopped ChildStatus = "stopped" StatusExited ChildStatus = "exited" StatusErrored ChildStatus = "errored" ) // ChildKind matches the three process kinds in SPEC §7. // - agent: vendor LLM CLI launched from an agent preset (MCP-wired, // ephemeral — lost when the PTY exits). // - terminal: a bare interactive shell (ephemeral). // - command: a process preset or freeform argv (session-persistent — // survives PTY exit so it can be restart_process'd). type ChildKind string const ( KindAgent ChildKind = "agent" KindTerminal ChildKind = "terminal" KindCommand ChildKind = "command" ) // Owner reflects the SPEC §6 input-ownership flag. type Owner string const ( OwnerUser Owner = "user" OwnerOrchestrator Owner = "orchestrator" ) // Child is one entry in the session — a PTY-backed process plus its // emulator. Covers all three kinds (agent / terminal / command). // // For KindCommand the entry is session-persistent: argv/env/workingDir // stay populated across stop/restart so Restart() can rebuild the PTY // against the same spec. type Child struct { ID string Name string Argv []string Env []string WorkDir string Kind ChildKind ParentID string // empty for top-level sessions // PresetRef names the source preset (when known). Used by trust // gating to re-check on restart_process. Empty for freeform-argv // command entries and for ephemeral terminals. PresetRef string // Identity is the per-spawn token the mcp-stdio proxy uses to // identify itself when calling tools. Empty for non-agent entries. Identity string // nameMu guards Name (rename_process). nameMu sync.RWMutex // ptyMu guards pty + em so Restart can swap them while pumpChild / // reapChild loops detect the swap by observing nil/closed PTY. ptyMu sync.RWMutex pty *pkgpty.PTY em *vt.GhosttyEmulator runID uint64 status atomic.Pointer[ChildStatus] exitCode atomic.Int32 owner atomic.Pointer[Owner] // lastWriteNS is the wall time of the most recent PTY-master write. // SPEC §11 idle heuristic: a pane is idle once nothing has been // written for the preset's threshold (default 1s). lastWriteNS atomic.Int64 // screenVersion increments on every PTY-out chunk. SPEC §7 // get_process_output exposes it so orchestrators can detect changes // without diffing content. screenVersion atomic.Int64 // ringMu guards ring. The ring buffer carries the last `ringCap` // bytes the PTY produced, used by SPEC §7 get_process_output stream // mode and search_output scrollback. The ring is a fixed-size byte // array with a wrap-around write index — no per-chunk reslice or // reallocation. StreamRead serves contiguous slices by copying out // of the (possibly wrapped) ring into a fresh buffer. ringMu sync.Mutex ring []byte // length == ringCap once allocated ringPos int // next byte to overwrite ringFull bool // true once ringWrites ≥ ringCap ringWrites int64 // cumulative bytes written // portsMu guards ports. Best-effort port detection: regex on stream. portsMu sync.Mutex ports []PortSighting // Idle-detection state. idleState carries the classifier's current // opinion (StateIdle / StateWorking / …). lastTitleNS is the wall // time of the most recent OSC title change — separate from // lastWriteNS so the osc_title_* strategies can ignore plain output // churn. idleDetection is the compiled per-preset config, resolved // once at spawn and immutable thereafter. idleState atomic.Pointer[IdleState] idleReason atomic.Pointer[string] titleMu sync.RWMutex title string lastTitleNS atomic.Int64 idleDetection *resolvedIdleDetection cleanupMu sync.Mutex cleanupPaths []string restarting atomic.Bool // autoRestart is set when the user spawned this command process with // "relaunch on exit". The session listener consults it after the PTY // exits and calls Start to bring the entry back up. Cleared when the // user explicitly kills the process from the palette. autoRestart atomic.Bool // persistFn is set by Session after Spawn registers the entry. The // callback mirrors mutable bits (name, auto-restart) into the // persist store so a restarted patterm can rebuild this entry. Nil // when no persist store is attached (unit tests / non-command // entries). persistMu sync.Mutex persistFn func(*Child) } func (c *Child) SetAutoRestart(v bool) { c.autoRestart.Store(v) c.firePersist() } func (c *Child) AutoRestart() bool { return c.autoRestart.Load() } func (c *Child) setPersistFn(fn func(*Child)) { c.persistMu.Lock() c.persistFn = fn c.persistMu.Unlock() } func (c *Child) firePersist() { c.persistMu.Lock() fn := c.persistFn c.persistMu.Unlock() if fn != nil { fn(c) } } // PortSighting is one entry returned by get_process_ports. type PortSighting struct { Port int `json:"port"` URL string `json:"url,omitempty"` FirstSeenAt string `json:"first_seen_at"` } const ringCap = 1 << 20 // 1 MiB per SPEC §5 // newChildEntry builds the in-memory Child record but does NOT start a PTY. func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID, workDir, presetRef string) *Child { c := &Child{ ID: id, Name: name, Argv: argv, Env: env, WorkDir: workDir, Kind: kind, ParentID: parentID, PresetRef: presetRef, ring: make([]byte, ringCap), } st := StatusStopped c.status.Store(&st) c.exitCode.Store(-1) def := OwnerUser if kind == KindAgent && parentID != "" { def = OwnerOrchestrator } c.owner.Store(&def) if kind == KindAgent { c.Identity = mintIdentity() } return c } // startPTY (re)builds the emulator + PTY for this entry. Called by // newChild on initial spawn and by Restart on subsequent runs. The // status transitions stopped/exited → starting → running. On error the // entry returns to errored. func (c *Child) startPTY(cols, rows uint16) (uint64, error) { em, err := vt.NewGhosttyEmulator(cols, rows) if err != nil { return 0, fmt.Errorf("child %s emulator: %w", c.ID, err) } starting := StatusStarting c.status.Store(&starting) p, err := pkgpty.Start(c.Argv, c.Env, cols, rows) if err != nil { em.Close() errored := StatusErrored c.status.Store(&errored) return 0, fmt.Errorf("child %s pty: %w", c.ID, err) } em.OnWritePTY(func(b []byte) { _, _ = p.Write(b) }) c.ptyMu.Lock() c.runID++ runID := c.runID c.pty = p c.em = em c.ptyMu.Unlock() running := StatusRunning c.status.Store(&running) c.exitCode.Store(-1) c.lastWriteNS.Store(0) return runID, nil } // IsLive reports whether the PTY is currently attached and running. // Used by callers that need to gate input on a live PTY (vs. a stopped // command entry). func (c *Child) IsLive() bool { st := c.Status() return st == StatusStarting || st == StatusRunning } // PTY returns the current PTY pointer under read-lock. May be nil for a // stopped command entry. func (c *Child) PTY() *pkgpty.PTY { c.ptyMu.RLock() defer c.ptyMu.RUnlock() return c.pty } // Emulator returns the current emulator pointer under read-lock. func (c *Child) Emulator() *vt.GhosttyEmulator { c.ptyMu.RLock() defer c.ptyMu.RUnlock() return c.em } func (c *Child) ptyForRun(runID uint64) *pkgpty.PTY { c.ptyMu.RLock() defer c.ptyMu.RUnlock() if c.runID != runID { return nil } return c.pty } func (c *Child) isCurrentRun(runID uint64) bool { c.ptyMu.RLock() defer c.ptyMu.RUnlock() return c.runID == runID } // DisplayName is the rename_process-aware accessor for Name. Callers // that read Name directly skip the lock; the field is still safe to // read because Go strings are immutable, but DisplayName signals intent. func (c *Child) DisplayName() string { c.nameMu.RLock() defer c.nameMu.RUnlock() return c.Name } // SetName updates the display name (rename_process). func (c *Child) SetName(name string) { c.nameMu.Lock() c.Name = name c.nameMu.Unlock() c.firePersist() } // ScreenVersion returns the current emulator snapshot version, bumped // on every PTY-out chunk. func (c *Child) ScreenVersion() int64 { return c.screenVersion.Load() } func (c *Child) Status() ChildStatus { st := c.status.Load() if st == nil { return StatusRunning } return *st } func (c *Child) ExitCode() int { return int(c.exitCode.Load()) } func (c *Child) PID() int { pty := c.PTY() if pty == nil { return 0 } return pty.Pid() } func (c *Child) Owner() Owner { o := c.owner.Load() if o == nil { return OwnerUser } return *o } func (c *Child) SetOwner(o Owner) { c.owner.Store(&o) } // IdleMS returns how many milliseconds since the last PTY write. // 0 means "no writes yet". SPEC §11. func (c *Child) IdleMS() int64 { last := c.lastWriteNS.Load() if last == 0 { return 0 } return (time.Now().UnixNano() - last) / int64(time.Millisecond) } // TitleIdleMS returns how many milliseconds since the OSC window title // last changed. 0 means "no title set yet". func (c *Child) TitleIdleMS() int64 { last := c.lastTitleNS.Load() if last == 0 { return 0 } return (time.Now().UnixNano() - last) / int64(time.Millisecond) } // Title returns the most recent OSC 0/2 title. func (c *Child) Title() string { c.titleMu.RLock() defer c.titleMu.RUnlock() return c.title } // recordTitle updates the cached title and bumps lastTitleNS when it // actually changes. Called from Session.pumpChild after each PTY chunk // — cheap because most chunks don't carry an OSC sequence. func (c *Child) recordTitle(newTitle string) { c.titleMu.Lock() if c.title == newTitle { c.titleMu.Unlock() return } c.title = newTitle c.titleMu.Unlock() c.lastTitleNS.Store(time.Now().UnixNano()) } // IdleState returns the classifier's current opinion. Empty string // (StateUnknown) means the classifier hasn't run yet for this child. func (c *Child) IdleState() IdleState { p := c.idleState.Load() if p == nil { return StateUnknown } return *p } // IdleReason returns the human-readable reason the classifier last // recorded. Empty when no classification has happened yet. func (c *Child) IdleReason() string { p := c.idleReason.Load() if p == nil { return "" } return *p } // setIdleState updates idleState + idleReason. Returns true when the // state actually changed (so callers can fan out a notification). func (c *Child) setIdleState(s IdleState, reason string) bool { prev := c.IdleState() if prev == s { return false } c.idleState.Store(&s) c.idleReason.Store(&reason) return true } // setIdleDetection installs the resolved per-preset idle-detection // config. Called once at spawn; not safe to swap at runtime. func (c *Child) setIdleDetection(r *resolvedIdleDetection) { c.idleDetection = r } func (c *Child) recordWrite(chunk []byte) { c.lastWriteNS.Store(time.Now().UnixNano()) c.screenVersion.Add(1) c.ringMu.Lock() // Chunks larger than ringCap are tail-truncated — only the last // ringCap bytes of the chunk can survive. src := chunk if len(src) > ringCap { src = src[len(src)-ringCap:] } for written := 0; written < len(src); { n := copy(c.ring[c.ringPos:], src[written:]) c.ringPos += n if c.ringPos >= ringCap { c.ringPos = 0 c.ringFull = true } written += n } c.ringWrites += int64(len(chunk)) c.ringMu.Unlock() c.scanPortsFromChunk(chunk) } // scanPortsFromChunk does best-effort port detection on a PTY chunk. // SPEC §7 get_process_ports — no probing, just stream scanning. func (c *Child) scanPortsFromChunk(chunk []byte) { // Cheap prefix check: most chunks don't contain a URL. Bail before // running the regex DFA over the whole chunk. if !bytes.Contains(chunk, []byte("http")) { return } matches := portRegex.FindAllSubmatch(chunk, -1) if len(matches) == 0 { return } now := time.Now().UTC().Format(time.RFC3339) c.portsMu.Lock() defer c.portsMu.Unlock() for _, m := range matches { urlForm := string(m[0]) portStr := string(m[1]) port, err := strconv.Atoi(portStr) if err != nil || port < 1 || port > 65535 { continue } seen := false for _, p := range c.ports { if p.Port == port { seen = true break } } if seen { continue } ent := PortSighting{Port: port, FirstSeenAt: now} if strings.HasPrefix(urlForm, "http") { ent.URL = urlForm } c.ports = append(c.ports, ent) } } // Ports returns a snapshot of detected port sightings. func (c *Child) Ports() []PortSighting { c.portsMu.Lock() defer c.portsMu.Unlock() out := make([]PortSighting, len(c.ports)) copy(out, c.ports) return out } // StreamRead returns ring bytes from `since` to the current write head, // plus the new offset. Offsets are absolute (cumulative bytes ever // written). If `since` is before the ring start, the caller missed // data; we return what we have and the new offset. func (c *Child) StreamRead(since int64) ([]byte, int64) { c.ringMu.Lock() defer c.ringMu.Unlock() end := c.ringWrites var ringStart int64 if c.ringFull { ringStart = end - int64(ringCap) } if since < ringStart { since = ringStart } if since >= end { return nil, end } n := int(end - since) out := make([]byte, n) // Locate `since` in the ring. When the buffer hasn't wrapped yet, // bytes 0..ringPos hold writes 0..ringPos. After wrap, ringPos // points at the oldest byte, and the freshest byte is at // (ringPos - 1) mod ringCap. var pos int if c.ringFull { skip := int(since - ringStart) // bytes after the oldest pos = (c.ringPos + skip) % ringCap } else { pos = int(since) } first := ringCap - pos if first > n { first = n } copy(out, c.ring[pos:pos+first]) if first < n { copy(out[first:], c.ring[:n-first]) } return out, end } func (c *Child) StreamOffset() int64 { c.ringMu.Lock() defer c.ringMu.Unlock() return c.ringWrites } func (c *Child) signal(sig syscall.Signal) error { pty := c.PTY() if pty == nil { return errors.New("child has no pty") } pid := pty.Pid() if pid <= 0 { return errors.New("child has no pid") } if err := syscall.Kill(-pid, sig); err == nil { return nil } return syscall.Kill(pid, sig) } // NudgeRedraw asks the child to throw away any diff-based render state // and emit a full frame on the next tick. Used after a focus switch so // ratatui/ink TUIs re-render coherently against the snapshot we just // replayed. Sends an explicit SIGWINCH; TIOCSWINSZ with the same size // is a no-op in the kernel, so an explicit signal is what most TUIs // actually act on anyway. Avoid resize-toggles here — under a drag- // resize the kernel still emits intermediate SIGWINCHes against the // host PTY and toggling our child's size on top produces inconsistent // grid state. func (c *Child) NudgeRedraw(cols, rows uint16) { pty := c.PTY() if pty == nil || rows < 2 { return } _ = c.signal(syscall.SIGWINCH) } func (c *Child) markExited(err error) { exitCode := int32(0) st := StatusExited if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { exitCode = int32(ee.ExitCode()) } else { exitCode = -1 st = StatusErrored } } c.exitCode.Store(exitCode) c.status.Store(&st) } // teardownPTY closes the current PTY/emulator and nils them out. Used // by Restart so the new PTY can take their place. Safe to call when // they're already nil. func (c *Child) teardownPTY() { c.ptyMu.Lock() p, em := c.pty, c.em c.pty, c.em = nil, nil c.ptyMu.Unlock() if p != nil { _ = p.Close() } if em != nil { _ = em.Close() } } func (c *Child) AddCleanupPath(path string) { if path == "" { return } c.cleanupMu.Lock() c.cleanupPaths = append(c.cleanupPaths, path) c.cleanupMu.Unlock() } func (c *Child) cleanupOwnedPaths() { c.cleanupMu.Lock() paths := c.cleanupPaths c.cleanupPaths = nil c.cleanupMu.Unlock() for _, p := range paths { _ = os.RemoveAll(p) } } // InjectAsUser is the path the human takes when typing in the focused // pane. SPEC §6: the user's first keystroke flips ownership. func (c *Child) InjectAsUser(b []byte) error { c.SetOwner(OwnerUser) return c.writeInput(b) } // InjectAsOrchestrator is the path send_message / initial_prompt / // timer_wait writes take. Ownership flips back to orchestrator. SPEC §6. func (c *Child) InjectAsOrchestrator(b []byte) error { c.SetOwner(OwnerOrchestrator) return c.writeInput(b) } // writeInput is the shared PTY write path used by both injection // flavours. Agent panes split each Enter byte (CR or LF) onto its own // write with a brief delay so TUI agents with paste-detection (claude, // codex, opencode) don't coalesce a trailing CR into the text that // preceded it. Raw terminals and command panes receive the original // byte stream in one write; otherwise a multiline paste pays the agent // workaround's delay once per line. func (c *Child) writeInput(b []byte) error { pty := c.PTY() if pty == nil { return errors.New("child has no pty") } pieces := inputWritePieces(c.Kind, b) if len(pieces) <= 1 { _, err := pty.Write(b) return err } for i, piece := range pieces { if delay := pieceWriteDelay(i, len(pieces), piece); delay > 0 { time.Sleep(delay) } if _, err := pty.Write(piece); err != nil { return err } } return nil } func inputWritePieces(kind ChildKind, b []byte) [][]byte { if kind != KindAgent { return [][]byte{b} } return splitOnEnter(b) } func pieceWriteDelay(index, total int, piece []byte) time.Duration { if index == 0 { return 0 } if index == total-1 && isLoneEnter(piece) { return agentSubmitSettleDelay } return agentInterPieceDelay } func isLoneEnter(piece []byte) bool { return len(piece) == 1 && (piece[0] == '\r' || piece[0] == '\n') } func mintIdentity() string { var buf [12]byte _, _ = rand.Read(buf[:]) return hex.EncodeToString(buf[:]) } // mintProcessID generates the opaque short token SPEC §7 calls a // process_id: lowercase `p_` followed by 6 hex chars. Collisions inside // one session are checked by the caller (session.go). func mintProcessID() string { var buf [3]byte _, _ = rand.Read(buf[:]) return "p_" + hex.EncodeToString(buf[:]) }