Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s
Bundles the in-flight work into the first tagged release. See CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list. Highlights: - Sidebar / chrome stability: clamp absolute cursor positioning and printable bytes to the viewport so long-running TUIs (claude, codex) can't spray into the right rail; bound tab bar's row clear to the viewport width so the rail isn't wiped on every tab redraw; flag scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K` to viewport columns. - Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill entries mark the focused tab, dead agents drop out of the switch list. - Sidebar: split into Processes (session-wide) + Agent Tree (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the combined list, Ctrl+A/D steps tabs. - MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`, `ping`), `mcp_injection.kind = cli_override / config_env` so codex and opencode pick up the server with no file writes, `lifecycle` help topic and tool-description cleanup-duty pointers. - Lifecycle: orchestrator-spawned children cascade-killed when the parent dies; orchestrator-injected prompts end with CR + delayed Enter so claude submits cleanly.
521 lines
14 KiB
Go
521 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"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]*)?`)
|
|
|
|
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.
|
|
ringMu sync.Mutex
|
|
ring []byte
|
|
ringStart int64 // absolute offset of ring[0]
|
|
ringWrites int64 // cumulative bytes written
|
|
|
|
// portsMu guards ports. Best-effort port detection: regex on stream.
|
|
portsMu sync.Mutex
|
|
ports []PortSighting
|
|
|
|
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
|
|
}
|
|
|
|
func (c *Child) SetAutoRestart(v bool) { c.autoRestart.Store(v) }
|
|
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
|
|
|
|
// 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, 0, 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()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func (c *Child) recordWrite(chunk []byte) {
|
|
c.lastWriteNS.Store(time.Now().UnixNano())
|
|
c.screenVersion.Add(1)
|
|
c.ringMu.Lock()
|
|
c.ring = append(c.ring, chunk...)
|
|
c.ringWrites += int64(len(chunk))
|
|
if len(c.ring) > ringCap {
|
|
drop := len(c.ring) - ringCap
|
|
c.ring = c.ring[drop:]
|
|
c.ringStart += int64(drop)
|
|
}
|
|
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) {
|
|
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()
|
|
if since < c.ringStart {
|
|
since = c.ringStart
|
|
}
|
|
end := c.ringStart + int64(len(c.ring))
|
|
if since >= end {
|
|
return nil, end
|
|
}
|
|
start := int(since - c.ringStart)
|
|
out := make([]byte, end-since)
|
|
copy(out, c.ring[start:])
|
|
return out, end
|
|
}
|
|
|
|
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. We toggle the PTY size by one row so the kernel reliably
|
|
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't
|
|
// change), then send SIGWINCH explicitly for TUIs that miss or coalesce
|
|
// the size-toggled signal. The emulator is left alone — it already
|
|
// matches our intended size and the brief mismatch only affects what the
|
|
// child writes during the second redraw.
|
|
func (c *Child) NudgeRedraw(cols, rows uint16) {
|
|
pty := c.PTY()
|
|
if pty == nil || rows < 2 {
|
|
return
|
|
}
|
|
_ = pty.Resize(cols, rows-1)
|
|
_ = pty.Resize(cols, rows)
|
|
_ = 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. Each Enter byte (CR or LF) is split 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. Without the split, `pty.Write([]byte("hello\r"))`
|
|
// arrives at the agent as one read() and gets treated as multi-line
|
|
// pasted content rather than "key Enter".
|
|
func (c *Child) writeInput(b []byte) error {
|
|
pty := c.PTY()
|
|
if pty == nil {
|
|
return errors.New("child has no pty")
|
|
}
|
|
pieces := splitOnEnter(b)
|
|
if len(pieces) <= 1 {
|
|
_, err := pty.Write(b)
|
|
return err
|
|
}
|
|
for i, piece := range pieces {
|
|
if i > 0 {
|
|
time.Sleep(15 * time.Millisecond)
|
|
}
|
|
if _, err := pty.Write(piece); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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[:])
|
|
}
|