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:
2026-05-14 14:29:45 +01:00
parent 2852c48e60
commit 55c6c93086
14 changed files with 2316 additions and 664 deletions

View File

@@ -16,9 +16,9 @@ import (
"golang.org/x/term"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/policy"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad"
"github.com/harrybrwn/patterm/internal/trust"
)
// Options configures a patterm run.
@@ -41,11 +41,6 @@ func Run(ctx context.Context, opts Options) error {
return fmt.Errorf("app: load presets: %w", err)
}
pol, err := policy.Load()
if err != nil {
return fmt.Errorf("app: load policy: %w", err)
}
// Ensure the per-project scratchpad dir exists so MCP and the UI
// can read/write into it. SPEC §3.
pads, err := scratchpad.Open(opts.ProjectKey)
@@ -53,6 +48,12 @@ func Run(ctx context.Context, opts Options) error {
return fmt.Errorf("app: scratchpad init: %w", err)
}
// Per-project trust store for command-preset trust gating (SPEC §7).
trustStore, err := trust.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: trust init: %w", err)
}
// In-process MCP server bound to the per-PID socket. Children that
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
// SPEC §10.
@@ -76,7 +77,7 @@ func Run(ctx context.Context, opts Options) error {
// Wire the tool host into MCP. Spawns through MCP use the host
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
// resize them later.
host := newToolHost(sess, pads, launcher, presets, pol, layout.childCols(), layout.childRows())
host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows())
mcpSrv.SetHost(host)
var restoreState *term.State
@@ -96,11 +97,14 @@ func Run(ctx context.Context, opts Options) error {
presets: presets,
launcher: launcher,
pads: pads,
trust: trustStore,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
}
host.attention = st
host.focus = st
host.prompter = st
st.lastExit.Store(-1)
sess.Subscribe(st)
@@ -200,6 +204,7 @@ type uiState struct {
presets preset.Set
launcher *Launcher
pads *scratchpad.Store
trust *trust.Store
outMu sync.Mutex
@@ -220,6 +225,13 @@ type uiState struct {
attentionText string
attentionAt string
// pendingTrust is the most recent trust prompt — surfaced in the
// status line until the user resolves it with Ctrl-K. v1 keeps the
// confirmation modal minimal: the user opens the palette and picks
// "Trust preset <name>" / "Deny preset <name>". A future iteration
// can promote this to a dedicated inline modal.
pendingTrust *trustRequest
dimsMu sync.Mutex
hostCols, hostRows uint16
stdinTTY bool
@@ -231,6 +243,42 @@ func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...)
}
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
// to spawn / start / restart against an untrusted command preset and
// the host wants user confirmation before the next attempt succeeds.
type trustRequest struct {
processID string
presetName string
reason string
}
// promptTrust is the SPEC §7 trust gate UI hook. Replaces any prior
// pending request — the most recent prompt wins.
func (st *uiState) promptTrust(processID, presetName, reason string) {
st.mu.Lock()
st.pendingTrust = &trustRequest{processID: processID, presetName: presetName, reason: reason}
st.mu.Unlock()
st.drawStatusLine()
}
// focusProcess is the SPEC §7 select_process hook. Routes through the
// normal focus-change path; only takes effect if the process exists.
func (st *uiState) focusProcess(processID string) {
c := st.sess.FindChild(processID)
if c == nil {
return
}
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.renderer = newViewportRenderer(st.layoutSnapshot())
st.mu.Unlock()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most
// recent ask so the status line keeps showing it. The sidebar-blink is
@@ -370,6 +418,10 @@ func (st *uiState) drawStatusLine() {
focusName := st.focusedName
attention := st.attentionText
attentionAt := st.attentionAt
var trustMsg string
if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
}
st.mu.Unlock()
if palOpen {
return
@@ -403,6 +455,13 @@ func (st *uiState) drawStatusLine() {
if attention != "" && attentionAt == focusID {
left = "[!] " + attention
}
if attention != "" && attentionAt == "" {
// Sticky attention/flash from somewhere outside the focused pane.
left = "[!] " + attention
}
if trustMsg != "" {
left = "[trust] " + trustMsg
}
right := "Ctrl-K · palette"
pad := int(cols) - len(left) - len(right)
@@ -490,6 +549,48 @@ func (st *uiState) stdinLoop() error {
func (st *uiState) processStdin(chunk []byte) {
st.mu.Lock()
// Trust modal is modal: y/Y accepts, n/N or ESC denies. Everything
// else is ignored so a typo doesn't leak into the focused PTY while
// the prompt is up. SPEC §7 trust gate.
if st.pendingTrust != nil {
req := *st.pendingTrust
consumed := 0
var resolved string
for _, b := range chunk {
consumed++
switch b {
case 'y', 'Y':
resolved = "accept"
case 'n', 'N', 0x1b: // ESC
resolved = "deny"
default:
continue
}
break
}
if resolved != "" {
st.pendingTrust = nil
st.mu.Unlock()
if resolved == "accept" {
if err := st.trust.Grant(req.presetName); err != nil {
st.flashError(fmt.Sprintf("trust grant: %v", err))
} else {
st.flashTransient(fmt.Sprintf("trusted preset %q (retry the call)", req.presetName))
}
} else {
st.flashTransient(fmt.Sprintf("denied trust for preset %q", req.presetName))
}
st.drawStatusLine()
// Discard the rest of the chunk; we intentionally don't
// recurse into the regular handler so a stray Enter doesn't
// submit anything to the focused PTY.
_ = consumed
return
}
st.mu.Unlock()
return
}
forward := make([]byte, 0, len(chunk))
flushForward := func() {
if len(forward) == 0 {
@@ -641,7 +742,7 @@ func (st *uiState) closePalette(action paletteAction) {
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchProcess(action.preset, action.preset.Name); err != nil {
if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
@@ -687,6 +788,16 @@ func (st *uiState) flashError(msg string) {
st.drawStatusLine()
}
// flashTransient is the softer cousin of flashError used for
// trust-prompt resolutions. Same status-line surface; the prefix differs.
func (st *uiState) flashTransient(msg string) {
st.mu.Lock()
st.attentionText = msg
st.attentionAt = ""
st.mu.Unlock()
st.drawStatusLine()
}
// repaintFocused redraws the current focused child's screen snapshot.
// Callers must NOT hold st.mu — repaintFocused takes it
// briefly itself.

View File

@@ -6,6 +6,9 @@ import (
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
@@ -15,20 +18,34 @@ import (
"github.com/harrybrwn/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 (
StatusRunning ChildStatus = "running"
StatusExited ChildStatus = "exited"
StatusErrored ChildStatus = "errored"
StatusStarting ChildStatus = "starting"
StatusRunning ChildStatus = "running"
StatusStopped ChildStatus = "stopped"
StatusExited ChildStatus = "exited"
StatusErrored ChildStatus = "errored"
)
// ChildKind matches the two preset flavours in SPEC §10.
// 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"
KindProcess ChildKind = "process"
KindAgent ChildKind = "agent"
KindTerminal ChildKind = "terminal"
KindCommand ChildKind = "command"
)
// Owner reflects the SPEC §6 input-ownership flag.
@@ -39,86 +56,192 @@ const (
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).
// 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 process presets.
// identify itself when calling tools. Empty for non-agent entries.
Identity string
pty *pkgpty.PTY
em *vt.GhosttyEmulator
// 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
status atomic.Pointer[ChildStatus]
exitCode atomic.Int32
owner atomic.Pointer[Owner]
// lastWrite is the wall time of the most recent PTY-master write.
// 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 read_output stream mode.
// 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
}
// 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
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)
}
// newChildEntry builds the in-memory Child record but does NOT start a
// PTY. Used so command entries can exist in the `stopped` state from the
// moment they're created. Agents and terminals call newChild() which
// chains newChildEntry + startPTY for the initial run.
func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID, workDir, presetRef string) *Child {
c := &Child{
ID: id,
Name: name,
Argv: argv,
Kind: kind,
ParentID: parentID,
pty: p,
em: em,
ring: make([]byte, 0, ringCap),
ID: id,
Name: name,
Argv: argv,
Env: env,
WorkDir: workDir,
Kind: kind,
ParentID: parentID,
PresetRef: presetRef,
ring: make([]byte, 0, ringCap),
}
st := StatusRunning
st := StatusStopped
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()
}
return c
}
func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, workDir, presetRef string) (*Child, error) {
if len(argv) == 0 {
return nil, errors.New("child: empty argv")
}
c := newChildEntry(id, name, kind, argv, env, parentID, workDir, presetRef)
if err := c.startPTY(cols, rows); err != nil {
return nil, err
}
return c, nil
}
// 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) error {
em, err := vt.NewGhosttyEmulator(cols, rows)
if err != nil {
return 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 fmt.Errorf("child %s pty: %w", c.ID, err)
}
em.OnWritePTY(func(b []byte) {
_, _ = p.Write(b)
})
return c, nil
c.ptyMu.Lock()
c.pty = p
c.em = em
c.ptyMu.Unlock()
running := StatusRunning
c.status.Store(&running)
c.exitCode.Store(-1)
c.lastWriteNS.Store(0)
return 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
}
// 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 {
@@ -129,7 +252,13 @@ func (c *Child) Status() ChildStatus {
func (c *Child) ExitCode() int { return int(c.exitCode.Load()) }
func (c *Child) PID() int { return c.pty.Pid() }
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()
@@ -153,8 +282,8 @@ func (c *Child) IdleMS() int64 {
func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1)
c.ringMu.Lock()
defer c.ringMu.Unlock()
c.ring = append(c.ring, chunk...)
c.ringWrites += int64(len(chunk))
if len(c.ring) > ringCap {
@@ -162,6 +291,52 @@ func (c *Child) recordWrite(chunk []byte) {
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,
@@ -185,7 +360,11 @@ func (c *Child) StreamRead(since int64) ([]byte, int64) {
}
func (c *Child) signal(sig syscall.Signal) error {
pid := c.pty.Pid()
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")
}
@@ -211,20 +390,43 @@ func (c *Child) markExited(err error) {
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()
}
}
// 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)
pty := c.PTY()
if pty == nil {
return errors.New("child has no pty")
}
_, err := 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.
// 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)
_, err := c.pty.Write(b)
pty := c.PTY()
if pty == nil {
return errors.New("child has no pty")
}
_, err := pty.Write(b)
return err
}
@@ -233,3 +435,12 @@ func mintIdentity() string {
_, _ = 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[:])
}

File diff suppressed because it is too large Load Diff

99
internal/app/host_test.go Normal file
View File

@@ -0,0 +1,99 @@
package app
import (
"strings"
"testing"
)
// mkChild builds a Child without starting a PTY. Use sparingly — the
// resulting child has no emulator / ring buffer / status pointer set
// the way newChild would.
func mkChild(id, name, parent string) *Child {
return &Child{ID: id, Name: name, ParentID: parent}
}
func TestClassifySendMessageOrchestratorToChild(t *testing.T) {
parent := mkChild("p_aaa", "claude-1", "")
child := mkChild("p_bbb", "codex-1", "p_aaa")
line, err := classifySendMessage(parent, child, parent.ID, "hi child")
if err != nil {
t.Fatalf("expected success, got %v", err)
}
if !strings.HasPrefix(line, "[orchestrator] hi child") {
t.Fatalf("parent→child should tag [orchestrator], got %q", line)
}
}
func TestClassifySendMessageChildToParent(t *testing.T) {
parent := mkChild("p_aaa", "claude-1", "")
child := mkChild("p_bbb", "codex-1", "p_aaa")
line, err := classifySendMessage(child, parent, child.ID, "status update")
if err != nil {
t.Fatalf("expected success, got %v", err)
}
if !strings.Contains(line, "[sub-agent:codex-1]") {
t.Fatalf("child→parent should tag [sub-agent:<name>], got %q", line)
}
}
func TestClassifySendMessageSiblingRejected(t *testing.T) {
parent := mkChild("p_aaa", "claude-1", "")
sibA := mkChild("p_bbb", "codex-1", "p_aaa")
sibB := mkChild("p_ccc", "codex-2", "p_aaa")
_ = parent
_, err := classifySendMessage(sibA, sibB, sibA.ID, "hey")
if err == nil {
t.Fatalf("sibling send_message should fail with not_related")
}
if !strings.Contains(err.Error(), "neither parent nor child") {
t.Fatalf("error should mention sibling routing rule, got %v", err)
}
}
func TestClassifySendMessageUnrelatedRejected(t *testing.T) {
// Two unrelated top-level processes — they have no shared lineage.
a := mkChild("p_aaa", "claude-1", "")
b := mkChild("p_bbb", "codex-1", "")
_, err := classifySendMessage(a, b, a.ID, "hi")
if err == nil {
t.Fatalf("unrelated top-level send_message should fail")
}
}
func TestClassifySendMessageCannotSendToSelf(t *testing.T) {
c := mkChild("p_aaa", "claude-1", "")
_, err := classifySendMessage(c, c, c.ID, "talking to myself")
if err == nil {
t.Fatalf("self-send should fail")
}
}
func TestClassifySendMessageNilCallerAcceptsTopLevel(t *testing.T) {
// Caller arrived over an MCP connection without a resolved patterm
// identity (top-level tool client). Target is top-level; should
// land as [orchestrator].
target := mkChild("p_aaa", "claude-1", "")
line, err := classifySendMessage(nil, target, "" /* unknown caller id */, "go")
if err != nil {
t.Fatalf("expected success for nil-caller → top-level target, got %v", err)
}
if !strings.HasPrefix(line, "[orchestrator] go") {
t.Fatalf("expected [orchestrator] tag, got %q", line)
}
}
func TestClassifySendMessageNilCallerRejectsNonTopLevelTarget(t *testing.T) {
parent := mkChild("p_aaa", "claude-1", "")
child := mkChild("p_bbb", "codex-1", "p_aaa")
_ = parent
_, err := classifySendMessage(nil, child, "", "hi")
if err == nil {
t.Fatalf("nil caller → non-top-level should fail")
}
}

View File

@@ -90,7 +90,15 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
// Spawn with the chosen identity.
cols, rows := l.size()
c, err := l.sess.spawnWithIdentity(displayName, KindAgent, argv, env, cols, rows, parentID, identity)
c, err := l.sess.Spawn(SpawnSpec{
Kind: KindAgent,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
PresetRef: p.Name,
Identity: identity,
}, cols, rows)
if err != nil {
_ = os.Remove(mcpConfigPath)
return nil, err
@@ -111,17 +119,72 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
return c, nil
}
// LaunchProcess spawns a process preset. No MCP injection; just argv.
func (l *Launcher) LaunchProcess(p *preset.Preset, displayName string) (*Child, error) {
if p.Kind != preset.KindProcess {
return nil, fmt.Errorf("launch: %q is not a process preset", p.Name)
// LaunchCommandPreset spawns a process preset as a SPEC §7 command
// entry. No MCP injection; just argv. The entry is session-persistent
// (survives PTY exit so it can be Restart'd).
func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID string) (*Child, error) {
if p.Kind != preset.KindCommand {
return nil, fmt.Errorf("launch: %q is not a command preset", p.Name)
}
env := l.sess.ChildEnv()
for k, v := range p.Env {
env = append(env, k+"="+v)
}
cols, rows := l.size()
return l.sess.Spawn(displayName, KindProcess, p.ResolvedArgv(), env, cols, rows, "")
return l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: p.ResolvedArgv(),
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: p.WorkingDir,
PresetRef: p.Name,
}, cols, rows)
}
// LaunchCommandArgv spawns a freeform-argv command entry. Trust gating
// (SPEC §7) lives one level up in toolHost — by the time we get here
// trust is settled (freeform argv is implicitly trusted).
func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workDir string, env []string, shell bool) (*Child, error) {
if shell && len(argv) > 0 {
argv = []string{"sh", "-lc", strings.Join(argv, " ")}
}
if env == nil {
env = l.sess.ChildEnv()
}
cols, rows := l.size()
return l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: workDir,
}, cols, rows)
}
// LaunchTerminal spawns a bare interactive shell. SPEC §7 kind=terminal.
// argv defaults to $SHELL -i when empty.
func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir string, env []string) (*Child, error) {
if len(argv) == 0 {
sh := os.Getenv("SHELL")
if sh == "" {
sh = "/bin/sh"
}
argv = []string{sh, "-i"}
}
if env == nil {
env = l.sess.ChildEnv()
}
cols, rows := l.size()
return l.sess.Spawn(SpawnSpec{
Kind: KindTerminal,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: workDir,
}, cols, rows)
}
func (l *Launcher) writeMCPConfig() (identity, path string, err error) {

View File

@@ -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()
}
}