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" "golang.org/x/term"
"github.com/harrybrwn/patterm/internal/mcp" "github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/policy"
"github.com/harrybrwn/patterm/internal/preset" "github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad" "github.com/harrybrwn/patterm/internal/scratchpad"
"github.com/harrybrwn/patterm/internal/trust"
) )
// Options configures a patterm run. // 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) 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 // Ensure the per-project scratchpad dir exists so MCP and the UI
// can read/write into it. SPEC §3. // can read/write into it. SPEC §3.
pads, err := scratchpad.Open(opts.ProjectKey) 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) 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 // In-process MCP server bound to the per-PID socket. Children that
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`. // support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
// SPEC §10. // 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 // Wire the tool host into MCP. Spawns through MCP use the host
// terminal's viewport grid for their initial PTY size; SIGWINCH paths // terminal's viewport grid for their initial PTY size; SIGWINCH paths
// resize them later. // 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) mcpSrv.SetHost(host)
var restoreState *term.State var restoreState *term.State
@@ -96,11 +97,14 @@ func Run(ctx context.Context, opts Options) error {
presets: presets, presets: presets,
launcher: launcher, launcher: launcher,
pads: pads, pads: pads,
trust: trustStore,
hostCols: cols, hostCols: cols,
hostRows: rows, hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
} }
host.attention = st host.attention = st
host.focus = st
host.prompter = st
st.lastExit.Store(-1) st.lastExit.Store(-1)
sess.Subscribe(st) sess.Subscribe(st)
@@ -200,6 +204,7 @@ type uiState struct {
presets preset.Set presets preset.Set
launcher *Launcher launcher *Launcher
pads *scratchpad.Store pads *scratchpad.Store
trust *trust.Store
outMu sync.Mutex outMu sync.Mutex
@@ -220,6 +225,13 @@ type uiState struct {
attentionText string attentionText string
attentionAt 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 dimsMu sync.Mutex
hostCols, hostRows uint16 hostCols, hostRows uint16
stdinTTY bool stdinTTY bool
@@ -231,6 +243,42 @@ func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...) 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 // notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most // 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 // recent ask so the status line keeps showing it. The sidebar-blink is
@@ -370,6 +418,10 @@ func (st *uiState) drawStatusLine() {
focusName := st.focusedName focusName := st.focusedName
attention := st.attentionText attention := st.attentionText
attentionAt := st.attentionAt 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() st.mu.Unlock()
if palOpen { if palOpen {
return return
@@ -403,6 +455,13 @@ func (st *uiState) drawStatusLine() {
if attention != "" && attentionAt == focusID { if attention != "" && attentionAt == focusID {
left = "[!] " + attention 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" right := "Ctrl-K · palette"
pad := int(cols) - len(left) - len(right) pad := int(cols) - len(left) - len(right)
@@ -490,6 +549,48 @@ func (st *uiState) stdinLoop() error {
func (st *uiState) processStdin(chunk []byte) { func (st *uiState) processStdin(chunk []byte) {
st.mu.Lock() 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)) forward := make([]byte, 0, len(chunk))
flushForward := func() { flushForward := func() {
if len(forward) == 0 { if len(forward) == 0 {
@@ -641,7 +742,7 @@ func (st *uiState) closePalette(action paletteAction) {
} }
l := st.layoutSnapshot() l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows()) 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)) st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
} }
@@ -687,6 +788,16 @@ func (st *uiState) flashError(msg string) {
st.drawStatusLine() 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. // repaintFocused redraws the current focused child's screen snapshot.
// Callers must NOT hold st.mu — repaintFocused takes it // Callers must NOT hold st.mu — repaintFocused takes it
// briefly itself. // briefly itself.

View File

@@ -6,6 +6,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os/exec" "os/exec"
"regexp"
"strconv"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
@@ -15,20 +18,34 @@ import (
"github.com/harrybrwn/patterm/internal/vt" "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 type ChildStatus string
const ( const (
StatusRunning ChildStatus = "running" StatusStarting ChildStatus = "starting"
StatusExited ChildStatus = "exited" StatusRunning ChildStatus = "running"
StatusErrored ChildStatus = "errored" 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 type ChildKind string
const ( const (
KindAgent ChildKind = "agent" KindAgent ChildKind = "agent"
KindProcess ChildKind = "process" KindTerminal ChildKind = "terminal"
KindCommand ChildKind = "command"
) )
// Owner reflects the SPEC §6 input-ownership flag. // Owner reflects the SPEC §6 input-ownership flag.
@@ -39,86 +56,192 @@ const (
OwnerOrchestrator Owner = "orchestrator" OwnerOrchestrator Owner = "orchestrator"
) )
// Child is one PTY-backed process plus its emulator. The same struct // Child is one entry in the session — a PTY-backed process plus its
// represents both agent presets (with MCP) and process presets (raw). // 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 { type Child struct {
ID string ID string
Name string Name string
Argv []string Argv []string
Env []string
WorkDir string
Kind ChildKind Kind ChildKind
ParentID string // empty for top-level sessions 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 // 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 Identity string
pty *pkgpty.PTY // nameMu guards Name (rename_process).
em *vt.GhosttyEmulator 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] status atomic.Pointer[ChildStatus]
exitCode atomic.Int32 exitCode atomic.Int32
owner atomic.Pointer[Owner] 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 // SPEC §11 idle heuristic: a pane is idle once nothing has been
// written for the preset's threshold (default 1s). // written for the preset's threshold (default 1s).
lastWriteNS atomic.Int64 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` // 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 ringMu sync.Mutex
ring []byte ring []byte
ringStart int64 // absolute offset of ring[0] ringStart int64 // absolute offset of ring[0]
ringWrites int64 // cumulative bytes written 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 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) { // newChildEntry builds the in-memory Child record but does NOT start a
if len(argv) == 0 { // PTY. Used so command entries can exist in the `stopped` state from the
return nil, errors.New("child: empty argv") // moment they're created. Agents and terminals call newChild() which
} // chains newChildEntry + startPTY for the initial run.
em, err := vt.NewGhosttyEmulator(cols, rows) func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID, workDir, presetRef string) *Child {
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{ c := &Child{
ID: id, ID: id,
Name: name, Name: name,
Argv: argv, Argv: argv,
Kind: kind, Env: env,
ParentID: parentID, WorkDir: workDir,
pty: p, Kind: kind,
em: em, ParentID: parentID,
ring: make([]byte, 0, ringCap), PresetRef: presetRef,
ring: make([]byte, 0, ringCap),
} }
st := StatusRunning st := StatusStopped
c.status.Store(&st) c.status.Store(&st)
c.exitCode.Store(-1) 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 def := OwnerUser
if kind == KindAgent && parentID != "" { if kind == KindAgent && parentID != "" {
def = OwnerOrchestrator def = OwnerOrchestrator
} }
c.owner.Store(&def) c.owner.Store(&def)
if kind == KindAgent { if kind == KindAgent {
c.Identity = mintIdentity() 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) { em.OnWritePTY(func(b []byte) {
_, _ = p.Write(b) _, _ = 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 { func (c *Child) Status() ChildStatus {
st := c.status.Load() st := c.status.Load()
if st == nil { 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) 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 { func (c *Child) Owner() Owner {
o := c.owner.Load() o := c.owner.Load()
@@ -153,8 +282,8 @@ func (c *Child) IdleMS() int64 {
func (c *Child) recordWrite(chunk []byte) { func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano()) c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1)
c.ringMu.Lock() c.ringMu.Lock()
defer c.ringMu.Unlock()
c.ring = append(c.ring, chunk...) c.ring = append(c.ring, chunk...)
c.ringWrites += int64(len(chunk)) c.ringWrites += int64(len(chunk))
if len(c.ring) > ringCap { if len(c.ring) > ringCap {
@@ -162,6 +291,52 @@ func (c *Child) recordWrite(chunk []byte) {
c.ring = c.ring[drop:] c.ring = c.ring[drop:]
c.ringStart += int64(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, // 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 { 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 { if pid <= 0 {
return errors.New("child has no pid") return errors.New("child has no pid")
} }
@@ -211,20 +390,43 @@ func (c *Child) markExited(err error) {
c.status.Store(&st) 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 // InjectAsUser is the path the human takes when typing in the focused
// pane. SPEC §6: the user's first keystroke flips ownership. // pane. SPEC §6: the user's first keystroke flips ownership.
func (c *Child) InjectAsUser(b []byte) error { func (c *Child) InjectAsUser(b []byte) error {
c.SetOwner(OwnerUser) 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 return err
} }
// InjectAsOrchestrator is the path send_message_to / report_to_parent / // InjectAsOrchestrator is the path send_message / initial_prompt /
// initial_prompt / timer_wait writes take. Ownership flips back to // timer_wait writes take. Ownership flips back to orchestrator. SPEC §6.
// orchestrator. SPEC §6.
func (c *Child) InjectAsOrchestrator(b []byte) error { func (c *Child) InjectAsOrchestrator(b []byte) error {
c.SetOwner(OwnerOrchestrator) 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 return err
} }
@@ -233,3 +435,12 @@ func mintIdentity() string {
_, _ = rand.Read(buf[:]) _, _ = rand.Read(buf[:])
return hex.EncodeToString(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. // Spawn with the chosen identity.
cols, rows := l.size() 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 { if err != nil {
_ = os.Remove(mcpConfigPath) _ = os.Remove(mcpConfigPath)
return nil, err return nil, err
@@ -111,17 +119,72 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
return c, nil return c, nil
} }
// LaunchProcess spawns a process preset. No MCP injection; just argv. // LaunchCommandPreset spawns a process preset as a SPEC §7 command
func (l *Launcher) LaunchProcess(p *preset.Preset, displayName string) (*Child, error) { // entry. No MCP injection; just argv. The entry is session-persistent
if p.Kind != preset.KindProcess { // (survives PTY exit so it can be Restart'd).
return nil, fmt.Errorf("launch: %q is not a process preset", p.Name) 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() env := l.sess.ChildEnv()
for k, v := range p.Env { for k, v := range p.Env {
env = append(env, k+"="+v) env = append(env, k+"="+v)
} }
cols, rows := l.size() 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) { func (l *Launcher) writeMCPConfig() (identity, path string, err error) {

View File

@@ -12,8 +12,8 @@ import (
"fmt" "fmt"
"os" "os"
"sync" "sync"
"sync/atomic"
"syscall" "syscall"
"time"
"github.com/harrybrwn/patterm/internal/vt" "github.com/harrybrwn/patterm/internal/vt"
) )
@@ -29,7 +29,10 @@ type Session struct {
children map[string]*Child children map[string]*Child
order []string 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 // listeners is the set of UI listeners that want to hear about child
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1. // lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
@@ -53,6 +56,7 @@ func NewSession(projectDir, projectKey string) *Session {
projectDir: projectDir, projectDir: projectDir,
projectKey: projectKey, projectKey: projectKey,
children: make(map[string]*Child), children: make(map[string]*Child),
nameSeq: make(map[ChildKind]int),
} }
} }
@@ -102,24 +106,45 @@ func (s *Session) ChildEnv() []string {
return env return env
} }
// Spawn launches a new child with the given argv. kind is "agent" or // SpawnSpec is the argument record for Session.Spawn — the new
// "process". parentID names the calling session/child for orchestrator // argv-shaped spawn API matching SPEC §7 spawn_process.
// trees ("" for top-level). env is the full child environment; the type SpawnSpec struct {
// caller is responsible for adding preset-specific overrides. Kind ChildKind
func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) { 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() s.mu.Lock()
id := fmt.Sprintf("c%d", s.nextChildSeq.Add(1)) id := s.mintUniqueIDLocked()
if name == "" { s.nameSeq[spec.Kind]++
name = fmt.Sprintf("%s-%s", kind, id) if spec.Name == "" {
spec.Name = fmt.Sprintf("%s-%d", spec.Kind, s.nameSeq[spec.Kind])
} }
s.mu.Unlock() s.mu.Unlock()
if env == nil { c := newChildEntry(id, spec.Name, spec.Kind, spec.Argv, spec.Env, spec.ParentID, spec.WorkDir, spec.PresetRef)
env = s.ChildEnv() if spec.Identity != "" {
c.Identity = spec.Identity
} }
if err := c.startPTY(cols, rows); err != nil {
c, err := newChild(id, name, kind, argv, env, cols, rows, parentID)
if err != nil {
return nil, err return nil, err
} }
@@ -134,27 +159,148 @@ func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, r
return c, nil return c, nil
} }
// spawnWithIdentity is like Spawn but lets the launcher pre-mint the // AddCommandEntry registers a command entry without starting it. Used
// MCP identity so the config file can be written before the process // by spawn_process(kind: command) when SPEC §7 needs the entry to exist
// starts. // in `stopped` state first (we always start it after; the indirection
func (s *Session) spawnWithIdentity(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, identity string) (*Child, error) { // is here so future versions can support deferred starts).
c, err := s.Spawn(name, kind, argv, env, cols, rows, parentID) func (s *Session) AddCommandEntry(spec SpawnSpec) *Child {
if err != nil { s.mu.Lock()
return nil, err 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) { func (s *Session) pumpChild(c *Child) {
buf := make([]byte, 64*1024) buf := make([]byte, 64*1024)
for { for {
n, err := c.pty.Read(buf) pty := c.PTY()
if pty == nil {
return
}
n, err := pty.Read(buf)
if n > 0 { if n > 0 {
chunk := make([]byte, n) chunk := make([]byte, n)
copy(chunk, buf[:n]) copy(chunk, buf[:n])
if _, werr := c.em.Write(chunk); werr != nil { if em := c.Emulator(); em != nil {
logf("emulator.Write(child %s): %v", c.ID, werr) if _, werr := em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr)
}
} }
c.recordWrite(chunk) c.recordWrite(chunk)
s.emitPTYOut(c.ID, chunk) s.emitPTYOut(c.ID, chunk)
@@ -169,7 +315,11 @@ func (s *Session) pumpChild(c *Child) {
} }
func (s *Session) reapChild(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) c.markExited(err)
logf("child %s exited (err=%v)", c.ID, err) logf("child %s exited (err=%v)", c.ID, err)
s.emitExit(c) s.emitExit(c)
@@ -233,7 +383,11 @@ func (s *Session) WriteInput(id string, b []byte) error {
if c.Status() != StatusRunning { if c.Status() != StatusRunning {
return fmt.Errorf("child %q is %s", id, c.Status()) 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 return err
} }
@@ -250,8 +404,12 @@ func (s *Session) ResizeAll(cols, rows uint16) {
} }
s.mu.Unlock() s.mu.Unlock()
for _, c := range cs { for _, c := range cs {
_ = c.pty.Resize(cols, rows) if pty := c.PTY(); pty != nil {
_ = c.em.Resize(cols, rows) _ = 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 { if c == nil {
return nil, fmt.Errorf("no such child %q", id) 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) { 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 { if c == nil {
return "", vt.CursorState{}, fmt.Errorf("no such child %q", id) 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 { if err != nil {
return "", vt.CursorState{}, err return "", vt.CursorState{}, err
} }
cursor, err := c.em.Cursor() cursor, err := em.Cursor()
if err != nil { if err != nil {
return "", vt.CursorState{}, err return "", vt.CursorState{}, err
} }
@@ -297,8 +463,7 @@ func (s *Session) Shutdown() {
// Close emulators and PTY masters. The reaper goroutines will fire // Close emulators and PTY masters. The reaper goroutines will fire
// emitExit as Wait() returns. // emitExit as Wait() returns.
for _, c := range cs { for _, c := range cs {
_ = c.pty.Close() c.teardownPTY()
_ = c.em.Close()
} }
} }

View File

@@ -9,42 +9,224 @@ import (
"github.com/harrybrwn/patterm/internal/scratchpad" "github.com/harrybrwn/patterm/internal/scratchpad"
) )
// ToolHost is the interface the in-process server uses to reach the // JSON-RPC error codes used by the patterm MCP surface. -32700..-32600
// running patterm process's state. The app package implements this so // are the standard parse/invalid-request codes; the SPEC-defined error
// internal/mcp doesn't import internal/app (which would be a cycle). // names live in the -32000 range with a structured `data.kind` so the
type ToolHost interface { // caller can branch on the error type rather than parsing strings.
Children() []ChildInfo const (
Spawn(callerID, name string, argv []string, shell bool) (ChildInfo, error) codeParseError = -32700
SpawnAgent(callerID, presetName, displayName, initialPrompt string) (ChildInfo, error) codeInvalidRequest = -32600
ReadOutput(callerID, childID, mode string, sinceOffset int) (content string, newOffset int, err error) codeMethodNotFound = -32601
SendInput(callerID, childID string, payload []byte, appendNewline bool) error codeInvalidParams = -32602
Kill(callerID, childID string, sig syscall.Signal) error codeInternal = -32000
SendMessageTo(callerID, targetID, message string) error codeNeedsTrust = -32010
ReportToParent(callerID, message string) error codeRoleForbidden = -32011
TimerWait(callerID string, seconds float64, label string) (string, error) codeNotRelated = -32012
WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (matched bool, snippet string, err error) codeNotFound = -32013
RequestHumanAttention(callerID, childID, reason string) error codeWrongKind = -32014
Scratchpads() *scratchpad.Store codeUnknownAgent = -32015
)
// ResolveCallerIdentity translates a per-spawn identity token into // CallerRole is one of "orchestrator" or "sub-agent". SPEC §7 caller
// the child ID the server stores in its connection state. // role: orchestrators sit at the root of a session tree; sub-agents
// were spawned by another agent.
type CallerRole string
const (
RoleOrchestrator CallerRole = "orchestrator"
RoleSubAgent CallerRole = "sub-agent"
)
// ToolHost is the interface the in-process server uses to reach the
// running patterm process's state. internal/app implements this; the
// split keeps internal/mcp free of an internal/app import (which would
// be cyclic).
type ToolHost interface {
// Identity resolution. The mcp-stdio greeting carries a per-spawn
// token; the server resolves it to a process_id before dispatching
// the rest of the connection's calls.
ResolveCallerIdentity(identity string) string ResolveCallerIdentity(identity string) string
// PolicyCheck — SPEC §9. Returns "allow" / "punt" / "unknown" for // CallerRole returns the role for the given process_id. Unknown
// a candidate auto-answer prompt the orchestrator is reading. // callers default to RoleOrchestrator (treated as a top-level peer)
PolicyCheck(prompt string) string // so they don't get silently denied.
CallerRole(processID string) CallerRole
// Lifecycle (SPEC §7).
SpawnAgent(callerID string, args SpawnAgentArgs) (ProcessInfo, error)
SpawnProcess(callerID string, args SpawnProcessArgs) (ProcessInfo, error)
StartProcess(callerID, processID string) (ProcessInfo, error)
RestartProcess(callerID, processID string, signal syscall.Signal) (ProcessInfo, error)
StopProcess(callerID, processID string, signal syscall.Signal) (ProcessInfo, error)
CloseProcess(callerID, processID string) error
RenameProcess(callerID, processID, name string) error
SelectProcess(callerID, processID string) error
// Inspection.
ListProcesses(callerID, kindFilter string) []ProcessInfo
GetProcessStatus(callerID, processID string) (ProcessStatus, error)
GetProjectStatus(callerID string) (ProjectStatus, error)
GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (ProcessOutput, error)
GetProcessRawOutput(callerID, processID string, sinceOffset int64) (RawOutput, error)
SearchOutput(callerID, processID, pattern, kind string, limit int) (SearchResult, error)
WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error)
GetProcessPorts(callerID, processID string) ([]PortSighting, error)
// I/O.
SendInput(callerID string, args SendInputArgs) (SendInputResult, error)
// Coordination.
SendMessage(callerID, targetID, message string) error
RequestHumanAttention(callerID, processID, reason string) error
TimerWait(callerID string, seconds float64, label string) (string, error)
// Scratchpads.
Scratchpads() *scratchpad.Store
// Meta.
WhoAmI(callerID string) WhoAmI
Help(callerID, topic string) HelpResponse
} }
// ChildInfo is what list_children / spawn_process / spawn_agent return. // ProcessInfo is the entry shape returned by list_processes, spawn_*,
// Matches SPEC §7 shape plus the §11 idle exposure. // stop_process, restart_process, start_process. SPEC §7.
type ChildInfo struct { type ProcessInfo struct {
ID string `json:"child_id"` ID string `json:"process_id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Kind string `json:"kind"`
Status string `json:"status"` Status string `json:"status"`
ExitCode int `json:"exit_code,omitempty"` ParentProcessID string `json:"parent_process_id,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"` ExitCode *int `json:"exit_code,omitempty"`
ParentID string `json:"parent_id,omitempty"` IdleMS int64 `json:"idle_ms,omitempty"`
Trusted *bool `json:"trusted,omitempty"`
}
// ProcessStatus is what get_process_status returns. Richer than
// ProcessInfo: includes pane geometry, cursor, and active screen.
type ProcessStatus struct {
ProcessInfo
WorkingDir string `json:"working_dir,omitempty"`
Argv []string `json:"argv,omitempty"`
StartedAt string `json:"started_at,omitempty"`
ActiveScreen string `json:"active_screen,omitempty"`
Rows int `json:"rows,omitempty"`
Cols int `json:"cols,omitempty"`
Cursor Cursor `json:"cursor"`
ScreenVersion int64 `json:"screen_version,omitempty"`
}
// Cursor matches SPEC §7's `{x, y}` payload.
type Cursor struct {
X int `json:"x"`
Y int `json:"y"`
}
// ProjectStatus is what get_project_status returns — everything an
// agent needs to orient itself in one call.
type ProjectStatus struct {
Project ProjectMeta `json:"project"`
Caller WhoAmI `json:"caller"`
Processes []ProcessInfo `json:"processes"`
Scratchpads []scratchpad.Entry `json:"scratchpads"`
}
// ProjectMeta is the project root info echoed in many payloads.
type ProjectMeta struct {
Path string `json:"path"`
Key string `json:"key"`
}
// ProcessOutput is the get_process_output payload. SPEC §7 enriches
// the old read_output result with screen geometry + version.
type ProcessOutput struct {
Content string `json:"content"`
Mode string `json:"mode"`
NewOffset int64 `json:"new_offset,omitempty"`
ActiveScreen string `json:"active_screen,omitempty"`
Rows int `json:"rows,omitempty"`
Cols int `json:"cols,omitempty"`
Cursor Cursor `json:"cursor"`
IdleMS int64 `json:"idle_ms,omitempty"`
Status string `json:"status,omitempty"`
ScreenVersion int64 `json:"screen_version,omitempty"`
}
// RawOutput is the get_process_raw_output payload — ANSI preserved.
type RawOutput struct {
Content string `json:"content"`
NewOffset int64 `json:"new_offset"`
Status string `json:"status,omitempty"`
}
// SearchResult is search_output's payload.
type SearchResult struct {
Matches []SearchMatch `json:"matches"`
Truncated bool `json:"truncated"`
}
type SearchMatch struct {
LineNo int `json:"line_no"`
Text string `json:"text"`
}
// PortSighting matches the per-child store in internal/app.
type PortSighting struct {
Port int `json:"port"`
URL string `json:"url,omitempty"`
FirstSeenAt string `json:"first_seen_at"`
}
// SpawnAgentArgs is the input shape for spawn_agent.
type SpawnAgentArgs struct {
Agent string `json:"agent"`
AgentInstructions string `json:"agent_instructions"`
Name string `json:"name"`
}
// SpawnProcessArgs is the input shape for spawn_process.
type SpawnProcessArgs struct {
Kind string `json:"kind"` // "terminal" | "command"
Preset string `json:"preset"`
Argv []string `json:"argv"`
Name string `json:"name"`
WorkingDir string `json:"working_dir"`
Env map[string]string `json:"env"`
Shell bool `json:"shell"`
}
// SendInputArgs is the input shape for send_input — covers text /
// paste / key with the optional wait+tail tail-after-send.
type SendInputArgs struct {
ProcessID string `json:"process_id"`
Kind string `json:"kind"` // "text" | "paste" | "key"
Text string `json:"text"`
Key string `json:"key"`
Submit *bool `json:"submit"`
WaitMS int `json:"wait_ms"`
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
}
// SendInputResult is the return shape of send_input.
type SendInputResult struct {
OK bool `json:"ok"`
Tail *ProcessOutput `json:"tail,omitempty"`
}
// WhoAmI is the whoami return shape.
type WhoAmI struct {
ProcessID string `json:"process_id"`
Name string `json:"name"`
Role CallerRole `json:"role"`
ParentProcessID string `json:"parent_process_id,omitempty"`
Project ProjectMeta `json:"project"`
AvailableTools []string `json:"available_tools"`
}
// HelpResponse is the help return shape.
type HelpResponse struct {
Topic string `json:"topic"`
Content string `json:"content"`
RelatedTools []string `json:"related_tools,omitempty"`
} }
func (s *Server) SetHost(h ToolHost) { func (s *Server) SetHost(h ToolHost) {
@@ -54,7 +236,7 @@ func (s *Server) SetHost(h ToolHost) {
} }
// dispatch routes a single JSON-RPC request. callerID is the ID of the // dispatch routes a single JSON-RPC request. callerID is the ID of the
// child that owns this connection (resolved at greeting time). // process that owns this connection (resolved at greeting time).
func (s *Server) dispatch(callerID string, req []byte) []byte { func (s *Server) dispatch(callerID string, req []byte) []byte {
var msg struct { var msg struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
@@ -63,152 +245,271 @@ func (s *Server) dispatch(callerID string, req []byte) []byte {
Params json.RawMessage `json:"params"` Params json.RawMessage `json:"params"`
} }
if err := json.Unmarshal(req, &msg); err != nil { if err := json.Unmarshal(req, &msg); err != nil {
return jsonRPCError(nil, -32700, "parse error: "+err.Error()) return jsonRPCError(nil, codeParseError, "parse error: "+err.Error(), nil)
} }
s.mu.Lock() s.mu.Lock()
host := s.host host := s.host
s.mu.Unlock() s.mu.Unlock()
if host == nil { if host == nil {
return jsonRPCError(msg.ID, -32000, "patterm: tool host not initialized") return jsonRPCError(msg.ID, codeInternal, "patterm: tool host not initialized", nil)
} }
result, code, errMsg := callTool(host, callerID, msg.Method, msg.Params) result, code, errMsg, data := callTool(host, callerID, msg.Method, msg.Params)
if errMsg != "" { if errMsg != "" {
return jsonRPCError(msg.ID, code, errMsg) return jsonRPCError(msg.ID, code, errMsg, data)
} }
return jsonRPCResult(msg.ID, result) return jsonRPCResult(msg.ID, result)
} }
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string) { // toolError lets host implementations attach a structured `kind` to a
// JSON-RPC error so callers can branch on error type without parsing
// the message. The MCP layer maps recognized error kinds to their SPEC
// error codes; unknown errors fall through to codeInternal.
type toolError struct {
Kind string `json:"kind"`
Message string `json:"message"`
}
func (e *toolError) Error() string { return e.Message }
// Errorf is a convenience for host implementations to return typed
// errors that map to JSON-RPC codes (needs_trust, role_forbidden, …).
func Errorf(kind, format string, a ...any) error {
return &toolError{Kind: kind, Message: fmt.Sprintf(format, a...)}
}
// callTool is the central dispatch. Returns (result, code, errMsg,
// errData). On success errMsg is empty.
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string, any) {
switch method { switch method {
case "list_children": case "spawn_agent":
return h.Children(), 0, "" var p SpawnAgentArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if p.Agent == "" {
return nil, codeInvalidParams, "spawn_agent: agent required", nil
}
// Role gate: only orchestrators may call spawn_agent (SPEC §8).
if h.CallerRole(callerID) == RoleSubAgent {
return nil, codeRoleForbidden, "spawn_agent: sub-agents cannot spawn agents; use vendor-native subagent tooling or ask your parent", structuredKind("role_forbidden")
}
info, err := h.SpawnAgent(callerID, p)
return mapToolResult(info, err)
case "spawn_process": case "spawn_process":
var p SpawnProcessArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
info, err := h.SpawnProcess(callerID, p)
return mapToolResult(info, err)
case "start_process":
var p struct{ ProcessID string `json:"process_id"` }
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
info, err := h.StartProcess(callerID, p.ProcessID)
return mapToolResult(info, err)
case "restart_process":
var p struct { var p struct {
Preset string `json:"preset"` ProcessID string `json:"process_id"`
Argv []string `json:"argv"` Signal int `json:"signal"`
Shell bool `json:"shell"`
Name string `json:"name"`
WorkingDir string `json:"working_dir"`
} }
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
// Preset-by-name is the preferred path per SPEC §7; argv is the sig := syscall.Signal(p.Signal)
// escape hatch. We don't load process presets here — the host info, err := h.RestartProcess(callerID, p.ProcessID, sig)
// is the source of truth — so a named preset call is rejected return mapToolResult(info, err)
// unless the caller also supplied argv. (Wiring full preset
// resolution into MCP is a small follow-up; the host's palette
// path covers the named case today.)
if len(p.Argv) == 0 {
return nil, -32602, "spawn_process: argv required"
}
ci, err := h.Spawn(callerID, p.Name, p.Argv, p.Shell)
if err != nil {
return nil, -32000, err.Error()
}
return ci, 0, ""
case "spawn_agent": case "stop_process":
var p struct { var p struct {
Preset string `json:"preset"` ProcessID string `json:"process_id"`
InitialPrompt string `json:"initial_prompt"` Signal int `json:"signal"`
Name string `json:"name"`
} }
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
if p.Preset == "" { sig := syscall.Signal(p.Signal)
return nil, -32602, "spawn_agent: preset required" info, err := h.StopProcess(callerID, p.ProcessID, sig)
} return mapToolResult(info, err)
ci, err := h.SpawnAgent(callerID, p.Preset, p.Name, p.InitialPrompt)
if err != nil {
return nil, -32000, err.Error()
}
return ci, 0, ""
case "read_output": case "close_process":
var p struct{ ProcessID string `json:"process_id"` }
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.CloseProcess(callerID, p.ProcessID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "rename_process":
var p struct { var p struct {
ChildID string `json:"child_id"` ProcessID string `json:"process_id"`
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.RenameProcess(callerID, p.ProcessID, p.Name); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "select_process":
var p struct{ ProcessID string `json:"process_id"` }
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.SelectProcess(callerID, p.ProcessID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "list_processes":
var p struct{ Kind string `json:"kind"` }
_ = unmarshalParamsOptional(params, &p)
return h.ListProcesses(callerID, p.Kind), 0, "", nil
case "get_process_status":
var p struct{ ProcessID string `json:"process_id"` }
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
st, err := h.GetProcessStatus(callerID, p.ProcessID)
if err != nil {
return mapToolError(err)
}
return st, 0, "", nil
case "get_project_status":
ps, err := h.GetProjectStatus(callerID)
if err != nil {
return mapToolError(err)
}
return ps, 0, "", nil
case "get_process_output":
var p struct {
ProcessID string `json:"process_id"`
Mode string `json:"mode"` Mode string `json:"mode"`
SinceOffset int `json:"since_offset"` SinceOffset int64 `json:"since_offset"`
} }
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
if p.Mode == "" { if p.Mode == "" {
p.Mode = "grid" p.Mode = "grid"
} }
content, newOff, err := h.ReadOutput(callerID, p.ChildID, p.Mode, p.SinceOffset) out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset)
if err != nil { if err != nil {
return nil, -32000, err.Error() return mapToolError(err)
} }
return map[string]any{ return out, 0, "", nil
"content": content,
"new_offset": newOff, case "get_process_raw_output":
"mode": p.Mode, var p struct {
}, 0, "" ProcessID string `json:"process_id"`
SinceOffset int64 `json:"since_offset"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
out, err := h.GetProcessRawOutput(callerID, p.ProcessID, p.SinceOffset)
if err != nil {
return mapToolError(err)
}
return out, 0, "", nil
case "search_output":
var p struct {
ProcessID string `json:"process_id"`
Pattern string `json:"pattern"`
Kind string `json:"kind"`
Limit int `json:"limit"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if p.Limit <= 0 {
p.Limit = 20
}
if p.Kind == "" {
p.Kind = "rendered"
}
res, err := h.SearchOutput(callerID, p.ProcessID, p.Pattern, p.Kind, p.Limit)
if err != nil {
return mapToolError(err)
}
return res, 0, "", nil
case "wait_for_pattern":
var p struct {
ProcessID string `json:"process_id"`
Pattern string `json:"pattern"`
TimeoutSeconds float64 `json:"timeout_seconds"`
Scope string `json:"scope"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
matched, snippet, err := h.WaitForPattern(callerID, p.ProcessID, p.Pattern, p.TimeoutSeconds, p.Scope)
if err != nil {
return mapToolError(err)
}
return map[string]any{"matched": matched, "snippet": snippet}, 0, "", nil
case "get_process_ports":
var p struct{ ProcessID string `json:"process_id"` }
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
ports, err := h.GetProcessPorts(callerID, p.ProcessID)
if err != nil {
return mapToolError(err)
}
return map[string]any{"ports": ports}, 0, "", nil
case "send_input": case "send_input":
var p struct { var p SendInputArgs
ChildID string `json:"child_id"`
Input string `json:"input"`
AppendNewline *bool `json:"append_newline"`
}
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
appendNL := true res, err := h.SendInput(callerID, p)
if p.AppendNewline != nil { if err != nil {
appendNL = *p.AppendNewline return mapToolError(err)
} }
if err := h.SendInput(callerID, p.ChildID, []byte(p.Input), appendNL); err != nil { return res, 0, "", nil
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "kill": case "send_message":
var p struct { var p struct {
ChildID string `json:"child_id"` TargetProcessID string `json:"target_process_id"`
Signal int `json:"signal"` Message string `json:"message"`
} }
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
sig := syscall.Signal(p.Signal) if err := h.SendMessage(callerID, p.TargetProcessID, p.Message); err != nil {
if sig == 0 { return mapToolError(err)
sig = syscall.SIGTERM
} }
if err := h.Kill(callerID, p.ChildID, sig); err != nil { return "ok", 0, "", nil
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "send_message_to": case "request_human_attention":
var p struct { var p struct {
Target string `json:"target"` ProcessID string `json:"process_id"`
Message string `json:"message"` Reason string `json:"reason"`
} }
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
if err := h.SendMessageTo(callerID, p.Target, p.Message); err != nil { if err := h.RequestHumanAttention(callerID, p.ProcessID, p.Reason); err != nil {
return nil, -32000, err.Error() return mapToolError(err)
} }
return "ok", 0, "" return "ok", 0, "", nil
case "report_to_parent":
var p struct {
Message string `json:"message"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
if err := h.ReportToParent(callerID, p.Message); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "timer_wait": case "timer_wait":
var p struct { var p struct {
@@ -216,61 +517,31 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
Label string `json:"label"` Label string `json:"label"`
} }
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
id, err := h.TimerWait(callerID, p.Seconds, p.Label) id, err := h.TimerWait(callerID, p.Seconds, p.Label)
if err != nil { if err != nil {
return nil, -32000, err.Error() return mapToolError(err)
} }
return map[string]string{"timer_id": id}, 0, "" return map[string]string{"timer_id": id}, 0, "", nil
case "wait_for_pattern":
var p struct {
ChildID string `json:"child_id"`
Pattern string `json:"pattern"`
TimeoutSeconds float64 `json:"timeout_seconds"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
matched, snippet, err := h.WaitForPattern(callerID, p.ChildID, p.Pattern, p.TimeoutSeconds)
if err != nil {
return nil, -32000, err.Error()
}
return map[string]any{"matched": matched, "snippet": snippet}, 0, ""
case "request_human_attention":
var p struct {
ChildID string `json:"child_id"`
Reason string `json:"reason"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
if err := h.RequestHumanAttention(callerID, p.ChildID, p.Reason); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "scratchpad_list": case "scratchpad_list":
entries, err := h.Scratchpads().List() entries, err := h.Scratchpads().List()
if err != nil { if err != nil {
return nil, -32000, err.Error() return nil, codeInternal, err.Error(), nil
} }
return entries, 0, "" return entries, 0, "", nil
case "scratchpad_read": case "scratchpad_read":
var p struct { var p struct{ Name string `json:"name"` }
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
content, rev, err := h.Scratchpads().Read(p.Name) content, rev, err := h.Scratchpads().Read(p.Name)
if err != nil { if err != nil {
return nil, -32000, err.Error() return nil, codeInternal, err.Error(), nil
} }
return map[string]any{"content": content, "revision": rev}, 0, "" return map[string]any{"content": content, "revision": rev}, 0, "", nil
case "scratchpad_write": case "scratchpad_write":
var p struct { var p struct {
@@ -279,22 +550,19 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
ExpectedRevision string `json:"expected_revision"` ExpectedRevision string `json:"expected_revision"`
} }
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
rev, err := h.Scratchpads().Write(p.Name, p.Content, p.ExpectedRevision) rev, err := h.Scratchpads().Write(p.Name, p.Content, p.ExpectedRevision)
if err != nil { if err != nil {
return nil, -32000, err.Error() // Optimistic-concurrency miss returns ok:false + current_revision
// rather than a JSON-RPC error so callers can re-read + merge.
var rme *scratchpad.RevisionMismatchError
if errors.As(err, &rme) {
return map[string]any{"ok": false, "current_revision": rme.CurrentRevision}, 0, "", nil
}
return nil, codeInternal, err.Error(), nil
} }
return map[string]any{"revision": rev}, 0, "" return map[string]any{"ok": true, "revision": rev}, 0, "", nil
case "policy_check":
var p struct {
Prompt string `json:"prompt"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
return map[string]string{"decision": h.PolicyCheck(p.Prompt)}, 0, ""
case "scratchpad_append": case "scratchpad_append":
var p struct { var p struct {
@@ -302,14 +570,62 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
Content string `json:"content"` Content string `json:"content"`
} }
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error() return nil, codeInvalidParams, err.Error(), nil
} }
if err := h.Scratchpads().Append(p.Name, p.Content); err != nil { if err := h.Scratchpads().Append(p.Name, p.Content); err != nil {
return nil, -32000, err.Error() return nil, codeInternal, err.Error(), nil
} }
return "ok", 0, "" return map[string]any{"ok": true}, 0, "", nil
case "whoami":
return h.WhoAmI(callerID), 0, "", nil
case "help":
var p struct{ Topic string `json:"topic"` }
_ = unmarshalParamsOptional(params, &p)
return h.Help(callerID, p.Topic), 0, "", nil
} }
return nil, -32601, "method not found: " + method return nil, codeMethodNotFound, "method not found: " + method, nil
}
// mapToolResult is the (info, err) → JSON-RPC reply helper for the
// many handlers that return a ProcessInfo-ish struct.
func mapToolResult(v any, err error) (any, int, string, any) {
if err != nil {
return mapToolError(err)
}
return v, 0, "", nil
}
// mapToolError translates a host error to (code, message, data). If the
// error is a *toolError its Kind names the SPEC-defined category and we
// pick a stable JSON-RPC code for that category; otherwise we return a
// generic internal error.
func mapToolError(err error) (any, int, string, any) {
var te *toolError
if errors.As(err, &te) {
code := codeInternal
switch te.Kind {
case "needs_trust":
code = codeNeedsTrust
case "role_forbidden":
code = codeRoleForbidden
case "not_related":
code = codeNotRelated
case "not_found":
code = codeNotFound
case "wrong_kind":
code = codeWrongKind
case "unknown_agent":
code = codeUnknownAgent
}
return nil, code, te.Message, structuredKind(te.Kind)
}
return nil, codeInternal, err.Error(), nil
}
func structuredKind(kind string) map[string]string {
return map[string]string{"kind": kind}
} }
func unmarshalParams(raw json.RawMessage, out any) error { func unmarshalParams(raw json.RawMessage, out any) error {
@@ -319,6 +635,13 @@ func unmarshalParams(raw json.RawMessage, out any) error {
return json.Unmarshal(raw, out) return json.Unmarshal(raw, out)
} }
func unmarshalParamsOptional(raw json.RawMessage, out any) error {
if len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, out)
}
func jsonRPCResult(id json.RawMessage, result any) []byte { func jsonRPCResult(id json.RawMessage, result any) []byte {
resp := map[string]any{ resp := map[string]any{
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -329,19 +652,19 @@ func jsonRPCResult(id json.RawMessage, result any) []byte {
return b return b
} }
func jsonRPCError(id json.RawMessage, code int, message string) []byte { func jsonRPCError(id json.RawMessage, code int, message string, data any) []byte {
errBody := map[string]any{
"code": code,
"message": message,
}
if data != nil {
errBody["data"] = data
}
resp := map[string]any{ resp := map[string]any{
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": id, "id": id,
"error": map[string]any{ "error": errBody,
"code": code,
"message": message,
},
} }
b, _ := json.Marshal(resp) b, _ := json.Marshal(resp)
return b return b
} }
// Compile-time guard: every dispatch path is covered. fmt is imported
// only so future error wrapping can land without re-adding the import.
var _ = fmt.Sprintf

View File

@@ -1,166 +0,0 @@
// Package policy implements SPEC §9 permissions hooks.
//
// patterm doesn't enforce permissions on the agent's behalf — the
// orchestrator is the policy actor. But patterm ships a config that
// surfaces the project's deny-list to the orchestrator (via
// scratchpad_read("policy.md")) and exposes a Should() helper future
// MCP middleware can call to short-circuit obviously-dangerous prompts.
//
// File location: $XDG_CONFIG_HOME/patterm/policy.json (global default;
// per-project override at <project>/.patterm/policy.json is a v2
// follow-up).
package policy
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
"sync"
)
// Decision is what Should() returns for a candidate auto-answer.
type Decision string
const (
// Allow: the prompt is in the always-safe allowlist; auto-answer.
Allow Decision = "allow"
// PuntToHuman: the prompt matches the deny-list; the orchestrator
// MUST call request_human_attention instead of auto-answering.
PuntToHuman Decision = "punt"
// Unknown: no rule applies. SPEC §9 says default is to punt; we
// keep this distinct so callers know it's a default, not a match.
Unknown Decision = "unknown"
)
type Policy struct {
// Allowlist patterns: prompts matching ANY of these are safe to
// auto-answer.
AllowPatterns []string `json:"allow_patterns"`
// Deny patterns: prompts matching ANY of these MUST be punted to
// the human. Default seed below covers SPEC §9 examples (writes,
// deletes, sudo, package install, broad shell).
DenyPatterns []string `json:"deny_patterns"`
mu sync.Mutex
compiledAOK bool
allowRE []*regexp.Regexp
denyRE []*regexp.Regexp
}
// Default returns the seeded policy that ships out of the box.
func Default() *Policy {
return &Policy{
AllowPatterns: []string{
`(?i)read.* from .*\?`,
`(?i)open .* in editor\?`,
`(?i)show diff\?`,
},
DenyPatterns: []string{
`(?i)sudo`,
`(?i)rm -rf`,
`(?i)delete .*permanently`,
`(?i)install package`,
`(?i)pip install`,
`(?i)npm install -g`,
`(?i)curl .* \| .*sh`,
`(?i)wget .* \| .*sh`,
`(?i)force.push`,
`(?i)git push --force`,
`(?i)drop (table|database)`,
`(?i)\.ssh/`,
`(?i)\.aws/credentials`,
`(?i)\.env\b`,
},
}
}
// Load reads the user's policy, falling back to Default if absent.
// Errors other than ENOENT are returned.
func Load() (*Policy, error) {
p, err := PathFor()
if err != nil {
return nil, err
}
body, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
pol := Default()
_ = Save(pol)
return pol, nil
}
return nil, err
}
var pol Policy
if err := json.Unmarshal(body, &pol); err != nil {
return nil, err
}
return &pol, nil
}
// Save writes p to the standard location, creating directories.
func Save(p *Policy) error {
path, err := PathFor()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
body, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
return os.WriteFile(path, body, 0o600)
}
func PathFor() (string, error) {
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
return filepath.Join(h, "patterm", "policy.json"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "patterm", "policy.json"), nil
}
// Should classifies a candidate auto-answer prompt the orchestrator is
// reading from a sub-agent's grid.
func (p *Policy) Should(promptText string) Decision {
p.mu.Lock()
defer p.mu.Unlock()
p.ensureCompiledLocked()
for _, re := range p.denyRE {
if re.MatchString(promptText) {
return PuntToHuman
}
}
for _, re := range p.allowRE {
if re.MatchString(promptText) {
return Allow
}
}
return Unknown
}
func (p *Policy) ensureCompiledLocked() {
if p.compiledAOK {
return
}
p.allowRE = make([]*regexp.Regexp, 0, len(p.AllowPatterns))
for _, s := range p.AllowPatterns {
if re, err := regexp.Compile(s); err == nil {
p.allowRE = append(p.allowRE, re)
}
}
p.denyRE = make([]*regexp.Regexp, 0, len(p.DenyPatterns))
for _, s := range p.DenyPatterns {
if re, err := regexp.Compile(s); err == nil {
p.denyRE = append(p.denyRE, re)
}
}
p.compiledAOK = true
}

View File

@@ -13,12 +13,15 @@ import (
"strings" "strings"
) )
// Kind is "agent" or "process". // Kind is "agent" or "command". Process presets are SPEC §7's
// `command` kind (session-persistent). Terminal entries don't have
// presets — they're created freeform with `kind: terminal` via
// spawn_process.
type Kind string type Kind string
const ( const (
KindAgent Kind = "agent" KindAgent Kind = "agent"
KindProcess Kind = "process" KindCommand Kind = "command"
) )
// Preset is one loaded preset file. Agent-only fields stay zero on // Preset is one loaded preset file. Agent-only fields stay zero on
@@ -87,7 +90,7 @@ func Load() (Set, error) {
if err != nil { if err != nil {
return Set{}, err return Set{}, err
} }
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindProcess) procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindCommand)
if err != nil { if err != nil {
return Set{}, err return Set{}, err
} }

View File

@@ -90,9 +90,21 @@ func (s *Store) Read(name string) (content string, revision string, err error) {
return string(b), revisionOf(b), nil return string(b), revisionOf(b), nil
} }
// RevisionMismatchError is returned by Write when expectedRevision
// doesn't match the on-disk revision. The MCP scratchpad_write tool
// surfaces CurrentRevision as `current_revision` in the response so
// the caller can re-read and merge (SPEC §7 / §14).
type RevisionMismatchError struct {
CurrentRevision string
}
func (e *RevisionMismatchError) Error() string {
return "scratchpad: revision mismatch (current=" + e.CurrentRevision + ")"
}
// Write replaces the file's contents. expectedRevision, if non-empty, // Write replaces the file's contents. expectedRevision, if non-empty,
// must match the current revision or the write is rejected (SPEC §14 // must match the current revision or the write is rejected with a
// last-write-wins-with-token). // *RevisionMismatchError (SPEC §14 last-write-wins-with-token).
func (s *Store) Write(name, content, expectedRevision string) (string, error) { func (s *Store) Write(name, content, expectedRevision string) (string, error) {
p, err := s.safePath(name) p, err := s.safePath(name)
if err != nil { if err != nil {
@@ -100,8 +112,9 @@ func (s *Store) Write(name, content, expectedRevision string) (string, error) {
} }
if expectedRevision != "" { if expectedRevision != "" {
if cur, err := os.ReadFile(p); err == nil { if cur, err := os.ReadFile(p); err == nil {
if revisionOf(cur) != expectedRevision { curRev := revisionOf(cur)
return "", fmt.Errorf("scratchpad: revision mismatch") if curRev != expectedRevision {
return "", &RevisionMismatchError{CurrentRevision: curRev}
} }
} }
} }

164
internal/trust/trust.go Normal file
View File

@@ -0,0 +1,164 @@
// Package trust implements SPEC §7's per-project command-preset trust
// gating. Command presets are not trusted by default; the first time
// an agent attempts to spawn / start / restart a process tied to one,
// the MCP tool returns a `needs_trust` error and patterm surfaces a UI
// confirmation. The user's acceptance is persisted to disk so the
// confirmation isn't repeated every run.
//
// Trust is keyed by `(project, preset name)` in v1. Freeform-argv
// command spawns bypass entirely (the agent had to compose the argv,
// so the trust decision is already implicit).
package trust
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
)
// Store is one project's trust file. Safe for concurrent use.
type Store struct {
path string
mu sync.RWMutex
granted map[string]bool
}
// Open loads (or creates) the trust file for projectKey. The file is
// stored at $XDG_DATA_HOME/patterm/projects/<projectKey>/trust.json
// (SPEC §3). Missing-file is not an error — it simply means no presets
// are trusted yet.
func Open(projectKey string) (*Store, error) {
if projectKey == "" {
return nil, errors.New("trust.Open: empty project key")
}
base, err := dataDir()
if err != nil {
return nil, err
}
dir := filepath.Join(base, "projects", projectKey)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("trust: mkdir %s: %w", dir, err)
}
path := filepath.Join(dir, "trust.json")
s := &Store{path: path, granted: make(map[string]bool)}
if err := s.loadLocked(); err != nil {
return nil, err
}
return s, nil
}
func dataDir() (string, error) {
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
return filepath.Join(h, "patterm"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", "patterm"), nil
}
// IsTrusted reports whether preset is granted.
func (s *Store) IsTrusted(preset string) bool {
if preset == "" {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.granted[preset]
}
// Grant records that preset is trusted and persists the file.
func (s *Store) Grant(preset string) error {
if preset == "" {
return errors.New("trust.Grant: empty preset")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.granted[preset] {
return nil
}
s.granted[preset] = true
return s.saveLocked()
}
// Revoke removes a trust grant. Not used by the SPEC v1 flow but
// useful for tests and future "untrust this" UI.
func (s *Store) Revoke(preset string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.granted[preset] {
return nil
}
delete(s.granted, preset)
return s.saveLocked()
}
// List returns the trusted presets in sorted order. For UI debugging.
func (s *Store) List() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.granted))
for k := range s.granted {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Path returns the trust file path. Used by tests / diagnostics.
func (s *Store) Path() string { return s.path }
type fileShape struct {
// Presets is the JSON shape on disk: a list of granted preset names.
// Using a list (not a map) keeps the file diff-friendly and ordering
// stable across re-saves.
Presets []string `json:"presets"`
}
func (s *Store) loadLocked() error {
b, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("trust: read %s: %w", s.path, err)
}
if len(b) == 0 {
return nil
}
var f fileShape
if err := json.Unmarshal(b, &f); err != nil {
return fmt.Errorf("trust: parse %s: %w", s.path, err)
}
for _, p := range f.Presets {
s.granted[p] = true
}
return nil
}
func (s *Store) saveLocked() error {
out := make([]string, 0, len(s.granted))
for k := range s.granted {
out = append(out, k)
}
sort.Strings(out)
body, err := json.MarshalIndent(fileShape{Presets: out}, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return fmt.Errorf("trust: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("trust: rename %s: %w", s.path, err)
}
return nil
}

View File

@@ -0,0 +1,61 @@
package trust
import (
"os"
"testing"
)
func TestStoreGrantPersists(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dir)
s1, err := Open("projkey")
if err != nil {
t.Fatalf("open: %v", err)
}
if s1.IsTrusted("vitest") {
t.Fatalf("fresh store should not trust anything")
}
if err := s1.Grant("vitest"); err != nil {
t.Fatalf("grant: %v", err)
}
if !s1.IsTrusted("vitest") {
t.Fatalf("grant didn't take effect")
}
// Re-open and confirm persistence.
s2, err := Open("projkey")
if err != nil {
t.Fatalf("reopen: %v", err)
}
if !s2.IsTrusted("vitest") {
t.Fatalf("grant did not persist across reopen")
}
if s2.IsTrusted("bun-dev") {
t.Fatalf("unrelated preset should not be trusted")
}
// Verify the file exists at the expected path.
if _, err := os.Stat(s2.Path()); err != nil {
t.Fatalf("stat trust.json: %v", err)
}
}
func TestStoreRevokeWithoutGrantIsNoop(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dir)
s, err := Open("projkey")
if err != nil {
t.Fatalf("open: %v", err)
}
if err := s.Revoke("never-granted"); err != nil {
t.Fatalf("revoke noop should succeed: %v", err)
}
}
func TestStoreOpenRequiresProjectKey(t *testing.T) {
if _, err := Open(""); err == nil {
t.Fatalf("open with empty project key should fail")
}
}

View File

@@ -212,6 +212,14 @@ func (e *GhosttyEmulator) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
// Size returns the current grid (cols, rows). SPEC §7 get_process_output
// and get_process_status surface these.
func (e *GhosttyEmulator) Size() (uint16, uint16) {
e.mu.Lock()
defer e.mu.Unlock()
return e.cols, e.rows
}
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { func (e *GhosttyEmulator) Resize(cols, rows uint16) error {
if cols == 0 || rows == 0 { if cols == 0 || rows == 0 {
return fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows) return fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)

View File

@@ -17,6 +17,7 @@ func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub } func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub } func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
func (e *GhosttyEmulator) Size() (uint16, uint16) { return 0, 0 }
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub } func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub } func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub } func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }