package app import ( "crypto/rand" "encoding/hex" "errors" "fmt" "os/exec" "sync" "sync/atomic" "syscall" "time" pkgpty "github.com/harrybrwn/patterm/internal/pty" "github.com/harrybrwn/patterm/internal/vt" ) type ChildStatus string const ( StatusRunning ChildStatus = "running" StatusExited ChildStatus = "exited" StatusErrored ChildStatus = "errored" ) // ChildKind matches the two preset flavours in SPEC §10. type ChildKind string const ( KindAgent ChildKind = "agent" KindProcess ChildKind = "process" ) // Owner reflects the SPEC §6 input-ownership flag. type Owner string const ( OwnerUser Owner = "user" OwnerOrchestrator Owner = "orchestrator" ) // Child is one PTY-backed process plus its emulator. The same struct // represents both agent presets (with MCP) and process presets (raw). type Child struct { ID string Name string Argv []string Kind ChildKind ParentID string // empty for top-level sessions // Identity is the per-spawn token the mcp-stdio proxy uses to // identify itself when calling tools. Empty for process presets. Identity string pty *pkgpty.PTY em *vt.GhosttyEmulator status atomic.Pointer[ChildStatus] exitCode atomic.Int32 owner atomic.Pointer[Owner] // lastWrite 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 // ringMu guards ring. The ring buffer carries the last `ringCap` // bytes the PTY produced, used by SPEC §7 read_output stream mode. ringMu sync.Mutex ring []byte ringStart int64 // absolute offset of ring[0] ringWrites int64 // cumulative bytes written } const ringCap = 1 << 20 // 1 MiB per SPEC §5 func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) { if len(argv) == 0 { return nil, errors.New("child: empty argv") } em, err := vt.NewGhosttyEmulator(cols, rows) if err != nil { return nil, fmt.Errorf("child %s emulator: %w", id, err) } p, err := pkgpty.Start(argv, env, cols, rows) if err != nil { em.Close() return nil, fmt.Errorf("child %s pty: %w", id, err) } c := &Child{ ID: id, Name: name, Argv: argv, Kind: kind, ParentID: parentID, pty: p, em: em, ring: make([]byte, 0, ringCap), } st := StatusRunning c.status.Store(&st) c.exitCode.Store(-1) // Agents spawned by an orchestrator default to orchestrator-owned; // everything else (top-level, processes) defaults to user. SPEC §6. def := OwnerUser if kind == KindAgent && parentID != "" { def = OwnerOrchestrator } c.owner.Store(&def) if kind == KindAgent { c.Identity = mintIdentity() } em.OnWritePTY(func(b []byte) { _, _ = p.Write(b) }) return c, nil } 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 { return c.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.ringMu.Lock() defer c.ringMu.Unlock() 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) } } // 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 { pid := c.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) } 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) } // 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) _, err := c.pty.Write(b) return err } // InjectAsOrchestrator is the path send_message_to / report_to_parent / // initial_prompt / timer_wait writes take. Ownership flips back to // orchestrator. SPEC §6. func (c *Child) InjectAsOrchestrator(b []byte) error { c.SetOwner(OwnerOrchestrator) _, err := c.pty.Write(b) return err } func mintIdentity() string { var buf [12]byte _, _ = rand.Read(buf[:]) return hex.EncodeToString(buf[:]) }