Initial patterm project
This commit is contained in:
235
internal/app/child.go
Normal file
235
internal/app/child.go
Normal file
@@ -0,0 +1,235 @@
|
||||
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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user