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 (
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"
KindTerminal ChildKind = "terminal"
KindCommand ChildKind = "command"
)
// Owner reflects the SPEC §6 input-ownership flag.
@@ -39,19 +56,36 @@ 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
// 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
@@ -60,65 +94,154 @@ type Child struct {
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,
Env: env,
WorkDir: workDir,
Kind: kind,
ParentID: parentID,
pty: p,
em: em,
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,28 +159,149 @@ 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 {
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()
}
}

View File

@@ -9,42 +9,224 @@ import (
"github.com/harrybrwn/patterm/internal/scratchpad"
)
// ToolHost is the interface the in-process server uses to reach the
// running patterm process's state. The app package implements this so
// internal/mcp doesn't import internal/app (which would be a cycle).
type ToolHost interface {
Children() []ChildInfo
Spawn(callerID, name string, argv []string, shell bool) (ChildInfo, error)
SpawnAgent(callerID, presetName, displayName, initialPrompt string) (ChildInfo, error)
ReadOutput(callerID, childID, mode string, sinceOffset int) (content string, newOffset int, err error)
SendInput(callerID, childID string, payload []byte, appendNewline bool) error
Kill(callerID, childID string, sig syscall.Signal) error
SendMessageTo(callerID, targetID, message string) error
ReportToParent(callerID, message string) error
TimerWait(callerID string, seconds float64, label string) (string, error)
WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (matched bool, snippet string, err error)
RequestHumanAttention(callerID, childID, reason string) error
Scratchpads() *scratchpad.Store
// JSON-RPC error codes used by the patterm MCP surface. -32700..-32600
// are the standard parse/invalid-request codes; the SPEC-defined error
// names live in the -32000 range with a structured `data.kind` so the
// caller can branch on the error type rather than parsing strings.
const (
codeParseError = -32700
codeInvalidRequest = -32600
codeMethodNotFound = -32601
codeInvalidParams = -32602
codeInternal = -32000
codeNeedsTrust = -32010
codeRoleForbidden = -32011
codeNotRelated = -32012
codeNotFound = -32013
codeWrongKind = -32014
codeUnknownAgent = -32015
)
// ResolveCallerIdentity translates a per-spawn identity token into
// the child ID the server stores in its connection state.
// CallerRole is one of "orchestrator" or "sub-agent". SPEC §7 caller
// 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
// PolicyCheck — SPEC §9. Returns "allow" / "punt" / "unknown" for
// a candidate auto-answer prompt the orchestrator is reading.
PolicyCheck(prompt string) string
// CallerRole returns the role for the given process_id. Unknown
// callers default to RoleOrchestrator (treated as a top-level peer)
// 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.
// Matches SPEC §7 shape plus the §11 idle exposure.
type ChildInfo struct {
ID string `json:"child_id"`
// ProcessInfo is the entry shape returned by list_processes, spawn_*,
// stop_process, restart_process, start_process. SPEC §7.
type ProcessInfo struct {
ID string `json:"process_id"`
Name string `json:"name"`
Type string `json:"type"`
Kind string `json:"kind"`
Status string `json:"status"`
ExitCode int `json:"exit_code,omitempty"`
ParentProcessID string `json:"parent_process_id,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"`
ParentID string `json:"parent_id,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) {
@@ -54,7 +236,7 @@ func (s *Server) SetHost(h ToolHost) {
}
// 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 {
var msg struct {
JSONRPC string `json:"jsonrpc"`
@@ -63,152 +245,271 @@ func (s *Server) dispatch(callerID string, req []byte) []byte {
Params json.RawMessage `json:"params"`
}
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()
host := s.host
s.mu.Unlock()
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 != "" {
return jsonRPCError(msg.ID, code, errMsg)
return jsonRPCError(msg.ID, code, errMsg, data)
}
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 {
case "list_children":
return h.Children(), 0, ""
case "spawn_agent":
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":
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 {
Preset string `json:"preset"`
Argv []string `json:"argv"`
Shell bool `json:"shell"`
Name string `json:"name"`
WorkingDir string `json:"working_dir"`
ProcessID string `json:"process_id"`
Signal int `json:"signal"`
}
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
// escape hatch. We don't load process presets here — the host
// is the source of truth — so a named preset call is rejected
// 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, ""
sig := syscall.Signal(p.Signal)
info, err := h.RestartProcess(callerID, p.ProcessID, sig)
return mapToolResult(info, err)
case "spawn_agent":
case "stop_process":
var p struct {
Preset string `json:"preset"`
InitialPrompt string `json:"initial_prompt"`
ProcessID string `json:"process_id"`
Signal int `json:"signal"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
sig := syscall.Signal(p.Signal)
info, err := h.StopProcess(callerID, p.ProcessID, sig)
return mapToolResult(info, err)
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 {
ProcessID string `json:"process_id"`
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
return nil, codeInvalidParams, err.Error(), nil
}
if p.Preset == "" {
return nil, -32602, "spawn_agent: preset required"
if err := h.RenameProcess(callerID, p.ProcessID, p.Name); err != nil {
return mapToolError(err)
}
ci, err := h.SpawnAgent(callerID, p.Preset, p.Name, p.InitialPrompt)
if err != nil {
return nil, -32000, err.Error()
}
return ci, 0, ""
return "ok", 0, "", nil
case "read_output":
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 {
ChildID string `json:"child_id"`
ProcessID string `json:"process_id"`
Mode string `json:"mode"`
SinceOffset int `json:"since_offset"`
SinceOffset int64 `json:"since_offset"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
return nil, codeInvalidParams, err.Error(), nil
}
if p.Mode == "" {
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 {
return nil, -32000, err.Error()
return mapToolError(err)
}
return map[string]any{
"content": content,
"new_offset": newOff,
"mode": p.Mode,
}, 0, ""
return out, 0, "", nil
case "get_process_raw_output":
var p struct {
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":
var p struct {
ChildID string `json:"child_id"`
Input string `json:"input"`
AppendNewline *bool `json:"append_newline"`
}
var p SendInputArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
return nil, codeInvalidParams, err.Error(), nil
}
appendNL := true
if p.AppendNewline != nil {
appendNL = *p.AppendNewline
res, err := h.SendInput(callerID, p)
if err != nil {
return mapToolError(err)
}
if err := h.SendInput(callerID, p.ChildID, []byte(p.Input), appendNL); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
return res, 0, "", nil
case "kill":
case "send_message":
var p struct {
ChildID string `json:"child_id"`
Signal int `json:"signal"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
sig := syscall.Signal(p.Signal)
if sig == 0 {
sig = syscall.SIGTERM
}
if err := h.Kill(callerID, p.ChildID, sig); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "send_message_to":
var p struct {
Target string `json:"target"`
TargetProcessID string `json:"target_process_id"`
Message string `json:"message"`
}
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 {
return nil, -32000, err.Error()
if err := h.SendMessage(callerID, p.TargetProcessID, p.Message); err != nil {
return mapToolError(err)
}
return "ok", 0, ""
return "ok", 0, "", nil
case "report_to_parent":
case "request_human_attention":
var p struct {
Message string `json:"message"`
ProcessID string `json:"process_id"`
Reason string `json:"reason"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ReportToParent(callerID, p.Message); err != nil {
return nil, -32000, err.Error()
if err := h.RequestHumanAttention(callerID, p.ProcessID, p.Reason); err != nil {
return mapToolError(err)
}
return "ok", 0, ""
return "ok", 0, "", nil
case "timer_wait":
var p struct {
@@ -216,61 +517,31 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
Label string `json:"label"`
}
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)
if err != nil {
return nil, -32000, err.Error()
return mapToolError(err)
}
return map[string]string{"timer_id": id}, 0, ""
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, ""
return map[string]string{"timer_id": id}, 0, "", nil
case "scratchpad_list":
entries, err := h.Scratchpads().List()
if err != nil {
return nil, -32000, err.Error()
return nil, codeInternal, err.Error(), nil
}
return entries, 0, ""
return entries, 0, "", nil
case "scratchpad_read":
var p struct {
Name string `json:"name"`
}
var p struct{ Name string `json:"name"` }
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)
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":
var p struct {
@@ -279,22 +550,19 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
ExpectedRevision string `json:"expected_revision"`
}
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)
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 map[string]any{"revision": rev}, 0, ""
case "policy_check":
var p struct {
Prompt string `json:"prompt"`
return nil, codeInternal, err.Error(), nil
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
return map[string]string{"decision": h.PolicyCheck(p.Prompt)}, 0, ""
return map[string]any{"ok": true, "revision": rev}, 0, "", nil
case "scratchpad_append":
var p struct {
@@ -302,14 +570,62 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
Content string `json:"content"`
}
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 {
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 {
@@ -319,6 +635,13 @@ func unmarshalParams(raw json.RawMessage, out any) error {
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 {
resp := map[string]any{
"jsonrpc": "2.0",
@@ -329,19 +652,19 @@ func jsonRPCResult(id json.RawMessage, result any) []byte {
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{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{
"code": code,
"message": message,
},
"error": errBody,
}
b, _ := json.Marshal(resp)
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"
)
// 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
const (
KindAgent Kind = "agent"
KindProcess Kind = "process"
KindCommand Kind = "command"
)
// Preset is one loaded preset file. Agent-only fields stay zero on
@@ -87,7 +90,7 @@ func Load() (Set, error) {
if err != nil {
return Set{}, err
}
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindProcess)
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindCommand)
if err != nil {
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
}
// 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,
// must match the current revision or the write is rejected (SPEC §14
// last-write-wins-with-token).
// must match the current revision or the write is rejected with a
// *RevisionMismatchError (SPEC §14 last-write-wins-with-token).
func (s *Store) Write(name, content, expectedRevision string) (string, error) {
p, err := s.safePath(name)
if err != nil {
@@ -100,8 +112,9 @@ func (s *Store) Write(name, content, expectedRevision string) (string, error) {
}
if expectedRevision != "" {
if cur, err := os.ReadFile(p); err == nil {
if revisionOf(cur) != expectedRevision {
return "", fmt.Errorf("scratchpad: revision mismatch")
curRev := revisionOf(cur)
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
}
// 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 {
if cols == 0 || rows == 0 {
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) 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) ScreenText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }