Sync MCP surface to SPEC §7 process model
Rename list_children/read_output/kill/send_message_to to their SPEC §7 process_id-shaped names; drop report_to_parent (direction inferred by send_message) and policy_check (replaced by per-project trust gating). Add the SPEC's missing tools: start_process, restart_process, close_process, rename_process, select_process, get_process_status, get_project_status, get_process_raw_output, search_output, get_process_ports, whoami, help. Process model now distinguishes agent/terminal/command kinds with opaque p_<6hex> IDs. Command entries are session-persistent so they survive PTY exit and can be Restart'd. Status enum gains starting and stopped. screen_version, port detection, and bracketed-paste send_input land alongside. Trust gating (internal/trust) replaces the regex policy: command-preset spawns return needs_trust on first use; the user confirms in a status-line modal and the grant persists to \$XDG_DATA_HOME/patterm/projects/<key>/trust.json. Tests cover send_message direction inference (parent↔child, sibling rejection, nil caller paths) and trust grant persistence across reopen.
This commit is contained in:
@@ -12,8 +12,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/vt"
|
||||
)
|
||||
@@ -29,7 +29,10 @@ type Session struct {
|
||||
children map[string]*Child
|
||||
order []string
|
||||
|
||||
nextChildSeq atomic.Int64
|
||||
// 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.
|
||||
@@ -53,6 +56,7 @@ func NewSession(projectDir, projectKey string) *Session {
|
||||
projectDir: projectDir,
|
||||
projectKey: projectKey,
|
||||
children: make(map[string]*Child),
|
||||
nameSeq: make(map[ChildKind]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,24 +106,45 @@ func (s *Session) ChildEnv() []string {
|
||||
return env
|
||||
}
|
||||
|
||||
// Spawn launches a new child with the given argv. kind is "agent" or
|
||||
// "process". parentID names the calling session/child for orchestrator
|
||||
// trees ("" for top-level). env is the full child environment; the
|
||||
// caller is responsible for adding preset-specific overrides.
|
||||
func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 := fmt.Sprintf("c%d", s.nextChildSeq.Add(1))
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s-%s", kind, id)
|
||||
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()
|
||||
|
||||
if env == nil {
|
||||
env = s.ChildEnv()
|
||||
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
|
||||
if spec.Identity != "" {
|
||||
c.Identity = spec.Identity
|
||||
}
|
||||
|
||||
c, err := newChild(id, name, kind, argv, env, cols, rows, parentID)
|
||||
if err != nil {
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -134,27 +159,148 @@ func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, r
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// spawnWithIdentity is like Spawn but lets the launcher pre-mint the
|
||||
// MCP identity so the config file can be written before the process
|
||||
// starts.
|
||||
func (s *Session) spawnWithIdentity(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, identity string) (*Child, error) {
|
||||
c, err := s.Spawn(name, kind, argv, env, cols, rows, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// AddCommandEntry registers a command entry without starting it. Used
|
||||
// by spawn_process(kind: command) when SPEC §7 needs the entry to exist
|
||||
// in `stopped` state first (we always start it after; the indirection
|
||||
// is here so future versions can support deferred starts).
|
||||
func (s *Session) AddCommandEntry(spec SpawnSpec) *Child {
|
||||
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])
|
||||
}
|
||||
if spec.Env == nil {
|
||||
spec.Env = s.ChildEnv()
|
||||
}
|
||||
c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
|
||||
s.children[id] = c
|
||||
s.order = append(s.order, id)
|
||||
s.mu.Unlock()
|
||||
s.emitSpawn(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pumpChild(c)
|
||||
go s.reapChild(c)
|
||||
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)
|
||||
}
|
||||
if c.IsLive() {
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
_ = c.signal(sig)
|
||||
// Wait briefly for the reaper to mark exited. We don't need
|
||||
// strict synchronization — the reaper will run regardless; we
|
||||
// just want startPTY to land after teardown.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsLive() && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if c.IsLive() {
|
||||
// Force.
|
||||
_ = c.signal(syscall.SIGKILL)
|
||||
for c.IsLive() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.teardownPTY()
|
||||
if err := c.startPTY(cols, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pumpChild(c)
|
||||
go s.reapChild(c)
|
||||
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() {
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
_ = c.signal(sig)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for c.IsLive() && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if c.IsLive() {
|
||||
_ = c.signal(syscall.SIGKILL)
|
||||
for c.IsLive() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.teardownPTY()
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
c.Identity = identity
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Session) pumpChild(c *Child) {
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
n, err := c.pty.Read(buf)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return
|
||||
}
|
||||
n, err := pty.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if _, werr := c.em.Write(chunk); werr != nil {
|
||||
logf("emulator.Write(child %s): %v", c.ID, werr)
|
||||
if em := c.Emulator(); em != nil {
|
||||
if _, werr := em.Write(chunk); werr != nil {
|
||||
logf("emulator.Write(child %s): %v", c.ID, werr)
|
||||
}
|
||||
}
|
||||
c.recordWrite(chunk)
|
||||
s.emitPTYOut(c.ID, chunk)
|
||||
@@ -169,7 +315,11 @@ func (s *Session) pumpChild(c *Child) {
|
||||
}
|
||||
|
||||
func (s *Session) reapChild(c *Child) {
|
||||
err := c.pty.Wait()
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return
|
||||
}
|
||||
err := pty.Wait()
|
||||
c.markExited(err)
|
||||
logf("child %s exited (err=%v)", c.ID, err)
|
||||
s.emitExit(c)
|
||||
@@ -233,7 +383,11 @@ func (s *Session) WriteInput(id string, b []byte) error {
|
||||
if c.Status() != StatusRunning {
|
||||
return fmt.Errorf("child %q is %s", id, c.Status())
|
||||
}
|
||||
_, err := c.pty.Write(b)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return fmt.Errorf("child %q has no pty", id)
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -250,8 +404,12 @@ func (s *Session) ResizeAll(cols, rows uint16) {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, c := range cs {
|
||||
_ = c.pty.Resize(cols, rows)
|
||||
_ = c.em.Resize(cols, rows)
|
||||
if pty := c.PTY(); pty != nil {
|
||||
_ = pty.Resize(cols, rows)
|
||||
}
|
||||
if em := c.Emulator(); em != nil {
|
||||
_ = em.Resize(cols, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +421,11 @@ func (s *Session) SerializeChild(id string) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no such child %q", id)
|
||||
}
|
||||
return c.em.SerializeVT()
|
||||
em := c.Emulator()
|
||||
if em == nil {
|
||||
return nil, fmt.Errorf("child %q has no emulator", id)
|
||||
}
|
||||
return em.SerializeVT()
|
||||
}
|
||||
|
||||
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||
@@ -271,11 +433,15 @@ func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
|
||||
if c == nil {
|
||||
return "", vt.CursorState{}, fmt.Errorf("no such child %q", id)
|
||||
}
|
||||
text, err := c.em.ScreenText()
|
||||
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 := c.em.Cursor()
|
||||
cursor, err := em.Cursor()
|
||||
if err != nil {
|
||||
return "", vt.CursorState{}, err
|
||||
}
|
||||
@@ -297,8 +463,7 @@ func (s *Session) Shutdown() {
|
||||
// Close emulators and PTY masters. The reaper goroutines will fire
|
||||
// emitExit as Wait() returns.
|
||||
for _, c := range cs {
|
||||
_ = c.pty.Close()
|
||||
_ = c.em.Close()
|
||||
c.teardownPTY()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user