Files
patterm/internal/app/host.go
Harry Bayliss 53f06b604f Normalize whitespace in grid get_process_output to save tokens
Grid snapshots pad every row to the full terminal width and leave the
bottom of the screen blank, so MCP grid reads carried a lot of dead
whitespace. Add normalizeGridText (CRLF/lone-CR to LF, right-trim each
line, collapse blank runs to a single blank, drop leading/trailing
blanks) and apply it to the grid branch of GetProcessOutput only.
Stream output, raw output, and WaitForPattern matching are untouched.

Resolves the terminal-read newline/token-waste TODO item.
2026-05-25 12:33:59 +01:00

1229 lines
43 KiB
Go

package app
import (
"fmt"
"regexp"
"strings"
"sync"
"syscall"
"time"
"unicode"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
pkgvt "github.com/hjbdev/patterm/internal/vt"
)
// attentionSink is implemented by uiState to surface
// request_human_attention notifications.
type attentionSink interface {
notifyAttention(processID, reason string)
}
// focusSink is implemented by uiState so MCP select_process can route
// to the existing focus-change path.
type focusSink interface {
focusProcess(processID string)
}
// trustPrompter is implemented by uiState to surface the SPEC §7 trust
// confirmation modal when an agent first uses an untrusted command
// preset. The host returns `needs_trust` to the caller; the prompt
// completes asynchronously and the next call from the same caller
// succeeds (or fails again if the user denied).
type trustPrompter interface {
promptTrust(processID, presetName, reason string)
}
type scratchpadSink interface {
scratchpadsChanged()
}
// toolHost adapts the running session + scratchpad store + trust store
// to the MCP ToolHost interface. SPEC §7 tools route through here.
type toolHost struct {
sess *Session
pads *scratchpad.Store
launcher *Launcher
presets preset.Set
trust *trust.Store
sizeMu sync.Mutex
defaultRow uint16
defaultCol uint16
startedAtMu sync.Mutex
startedAt map[string]time.Time
attention attentionSink
focus focusSink
prompter trustPrompter
scratch scratchpadSink
timers *timerManager
}
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
h := &toolHost{
sess: sess,
pads: pads,
launcher: launcher,
presets: presets,
trust: tr,
defaultCol: cols,
defaultRow: rows,
startedAt: make(map[string]time.Time),
}
h.timers = newTimerManager(sess)
// Plug the timer manager into the session's state-change fan-out so
// idle-aware timers fire when watched children transition into idle.
// Tests can construct a host with a nil session for sizing checks —
// those never run timers, so the subscribe is skipped.
if sess != nil {
sess.Subscribe(timerListenerAdapter{m: h.timers})
}
return h
}
// timerListenerAdapter forwards OnChildStateChanged and OnChildClosed
// into the timer manager and ignores the other ChildEventListener
// methods. The session's listener API is by-interface, so we wrap
// the manager rather than make it implement the full surface.
type timerListenerAdapter struct{ m *timerManager }
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
func (a timerListenerAdapter) OnChildExited(*Child) {}
func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
a.m.onChildStateChanged(id, st)
}
func (a timerListenerAdapter) OnChildClosed(id string) {
a.m.onChildClosed(id)
}
func (h *toolHost) SetSize(cols, rows uint16) {
h.sizeMu.Lock()
defer h.sizeMu.Unlock()
h.defaultCol = cols
h.defaultRow = rows
}
func (h *toolHost) size() (uint16, uint16) {
h.sizeMu.Lock()
defer h.sizeMu.Unlock()
return h.defaultCol, h.defaultRow
}
// ResolveCallerIdentity maps an mcp-stdio greeting token to a
// process_id so subsequent tool calls on this connection know who's
// asking.
func (h *toolHost) ResolveCallerIdentity(identity string) string {
c := h.sess.FindChildByIdentity(identity)
if c == nil {
return ""
}
return c.ID
}
// CallerRole inspects the caller's parent. Top-level callers are
// orchestrators; descendants are sub-agents. Unknown callers are
// treated as orchestrators so they don't get silently denied — this
// matches SPEC §7's caller-role concept where the role enforces what
// the caller can do, not who they are.
func (h *toolHost) CallerRole(processID string) mcp.CallerRole {
if processID == "" {
return mcp.RoleOrchestrator
}
c := h.sess.FindChild(processID)
if c == nil {
return mcp.RoleOrchestrator
}
if c.ParentID == "" {
return mcp.RoleOrchestrator
}
return mcp.RoleSubAgent
}
// ───────────────────────────────────────────────────────────────────
// Lifecycle
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.ProcessInfo, error) {
var p *preset.Preset
for _, ap := range h.presets.Agents {
if ap.Name == args.Agent {
p = ap
break
}
}
if p == nil {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindUnknownAgent, "unknown agent preset %q", args.Agent)
}
display := args.Name
if display == "" {
display = args.Agent
}
prompt := wrapSubAgentPrompt(args.AgentInstructions, h.sess.FindChild(callerID) != nil)
c, err := h.launcher.LaunchAgent(p, display, prompt, callerID)
if err != nil {
return mcp.ProcessInfo{}, err
}
h.recordStart(c.ID)
return h.processInfoOf(c), nil
}
func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp.ProcessInfo, error) {
if args.Kind == "" {
args.Kind = "command"
}
if args.Kind != "command" && args.Kind != "terminal" {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindInvalidKind, "spawn_process: kind must be 'command' or 'terminal'")
}
env := h.mergeEnv(args.Env)
if args.Kind == "terminal" {
c, err := h.launcher.LaunchTerminal(args.Argv, h.terminalName(args.Name), callerID, args.WorkingDir, env)
if err != nil {
return mcp.ProcessInfo{}, err
}
h.recordStart(c.ID)
return h.processInfoOf(c), nil
}
// kind == "command"
if args.Preset != "" {
if !h.trust.IsTrusted(args.Preset) {
h.askForTrust(callerID, args.Preset, "spawn_process")
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNeedsTrust, "command preset %q is not trusted in this project — patterm has surfaced a confirmation; retry after the user accepts", args.Preset)
}
ps := h.commandPresetByName(args.Preset)
if ps == nil {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "command preset %q not found", args.Preset)
}
display := args.Name
if display == "" {
display = ps.Name
}
c, err := h.launcher.LaunchCommandPreset(ps, display, callerID)
if err != nil {
return mcp.ProcessInfo{}, err
}
h.recordStart(c.ID)
return h.processInfoOf(c), nil
}
if len(args.Argv) == 0 {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "spawn_process: either preset or argv required")
}
display := args.Name
if display == "" {
display = args.Argv[0]
}
c, err := h.launcher.LaunchCommandArgv(args.Argv, display, callerID, args.WorkingDir, env, args.Shell)
if err != nil {
return mcp.ProcessInfo{}, err
}
h.recordStart(c.ID)
return h.processInfoOf(c), nil
}
func (h *toolHost) StartProcess(callerID, processID string) (mcp.ProcessInfo, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if c.Kind != KindCommand {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindWrongKind, "start_process: only command entries can be started post-creation (this is %s)", c.Kind)
}
if c.IsLive() {
return h.processInfoOf(c), nil
}
if c.PresetRef != "" && !h.trust.IsTrusted(c.PresetRef) {
h.askForTrust(callerID, c.PresetRef, "start_process")
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNeedsTrust, "command preset %q is not trusted in this project", c.PresetRef)
}
cols, rows := h.size()
if err := h.sess.Start(processID, cols, rows); err != nil {
return mcp.ProcessInfo{}, err
}
h.recordStart(processID)
return h.processInfoOf(c), nil
}
func (h *toolHost) RestartProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if c.Kind != KindCommand && !c.IsLive() {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindWrongKind, "restart_process: %s entries can only be restarted while live", c.Kind)
}
if c.Kind == KindCommand && c.PresetRef != "" && !h.trust.IsTrusted(c.PresetRef) {
h.askForTrust(callerID, c.PresetRef, "restart_process")
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNeedsTrust, "command preset %q is not trusted in this project", c.PresetRef)
}
cols, rows := h.size()
if err := h.sess.Restart(processID, sig, cols, rows); err != nil {
return mcp.ProcessInfo{}, err
}
h.recordStart(processID)
return h.processInfoOf(c), nil
}
func (h *toolHost) StopProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if err := h.sess.Kill(processID, sig); err != nil {
return mcp.ProcessInfo{}, err
}
return h.processInfoOf(c), nil
}
func (h *toolHost) CloseProcess(callerID, processID string) error {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
_ = c // close removes by id; the lookup just validates existence.
return h.sess.Close(processID, syscall.SIGTERM)
}
func (h *toolHost) RenameProcess(callerID, processID, name string) error {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if name == "" {
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "rename_process: name required")
}
c.SetName(name)
return nil
}
func (h *toolHost) SelectProcess(callerID, processID string) error {
if h.sess.FindChild(processID) == nil {
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if h.focus != nil {
h.focus.focusProcess(processID)
}
return nil
}
// ───────────────────────────────────────────────────────────────────
// Inspection
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) ListProcesses(callerID, kindFilter string) []mcp.ProcessInfo {
cs := h.sess.Children()
out := make([]mcp.ProcessInfo, 0, len(cs))
for _, c := range cs {
if kindFilter != "" && string(c.Kind) != kindFilter {
continue
}
out = append(out, h.processInfoOf(c))
}
return out
}
func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStatus, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessStatus{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
info := h.processInfoOf(c)
st := mcp.ProcessStatus{ProcessInfo: info}
st.WorkingDir = c.WorkDir
st.Argv = append([]string(nil), c.Argv...)
if t := h.startedAtOf(c.ID); !t.IsZero() {
st.StartedAt = t.UTC().Format(time.RFC3339)
}
if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil {
st.ActiveScreen = activeScreenName(sc)
}
if cur, err := em.Cursor(); err == nil {
st.Cursor = mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
}
cols, rows := em.Size()
st.Cols, st.Rows = int(cols), int(rows)
}
st.ScreenVersion = c.ScreenVersion()
return st, nil
}
func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
caller := h.WhoAmI(callerID)
processes := h.ListProcesses(callerID, "")
pads, _ := h.pads.List()
return mcp.ProjectStatus{
Project: caller.Project,
Caller: caller,
Processes: processes,
Scratchpads: pads,
}, nil
}
func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
out := mcp.ProcessOutput{
Mode: mode,
IdleMS: c.IdleMS(),
Status: string(c.Status()),
ScreenVersion: c.ScreenVersion(),
}
if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil {
out.ActiveScreen = activeScreenName(sc)
}
if cur, err := em.Cursor(); err == nil {
out.Cursor = mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
}
cols, rows := em.Size()
out.Cols, out.Rows = int(cols), int(rows)
}
switch mode {
case "grid":
em := c.Emulator()
if em == nil {
return out, nil
}
txt, err := em.PlainText()
if err != nil {
return mcp.ProcessOutput{}, err
}
if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
}
out.Content = normalizeGridText(txt)
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
out.Content = string(stripANSIBytes(nil, b))
out.NewOffset = end
return out, nil
default:
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown mode %q (want grid|stream)", mode)
}
}
func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
b, end := c.StreamRead(sinceOffset)
return mcp.RawOutput{
Content: string(b),
NewOffset: end,
Status: string(c.Status()),
}, nil
}
func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
re, err := regexp.Compile(pattern)
if err != nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
b, _ := c.StreamRead(0)
if kind == "rendered" {
b = stripANSIBytes(nil, b)
}
text := string(b)
lines := strings.Split(text, "\n")
matches := make([]mcp.SearchMatch, 0, limit)
truncated := false
for i, line := range lines {
if re.MatchString(line) {
if len(matches) >= limit {
truncated = true
break
}
matches = append(matches, mcp.SearchMatch{LineNo: i + 1, Text: line})
}
}
return mcp.SearchResult{Matches: matches, Truncated: truncated}, nil
}
func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (bool, string, error) {
c := h.sess.FindChild(processID)
if c == nil {
return false, "", mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
re, err := regexp.Compile(pattern)
if err != nil {
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
if scope == "" {
scope = "grid"
}
if scope != "grid" && scope != "scrollback" {
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
}
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
// chunkWake fires on every PTY chunk for the target child. The
// fallback timer guarantees we still re-check on grid-only sweeps
// where the cursor position changed without a fresh chunk landing.
wake := newChunkNotifier(c.ID)
h.sess.Subscribe(wake)
defer h.sess.Unsubscribe(wake)
check := func() (bool, string) {
text := ""
switch scope {
case "grid":
if em := c.Emulator(); em != nil {
if t, err := em.PlainText(); err == nil {
text = t
}
}
case "scrollback":
b, _ := c.StreamRead(0)
text = string(stripANSIBytes(nil, b))
}
if m := re.FindString(text); m != "" {
return true, m
}
return false, ""
}
if ok, m := check(); ok {
return true, m, nil
}
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "", nil
}
// Long fallback tick — the chunk notifier wakes us promptly
// on fresh PTY output; the timer is only there for cases
// where grid state shifted without a new chunk.
wait := 500 * time.Millisecond
if remaining < wait {
wait = remaining
}
select {
case <-wake.fired:
case <-time.After(wait):
}
if ok, m := check(); ok {
return true, m, nil
}
if !c.IsLive() && c.Status() != StatusStopped {
return false, "", nil
}
}
}
// chunkNotifier is a one-shot-per-chunk wake channel listener.
// Registers via Session.Subscribe; emits a non-blocking signal on
// `fired` for every PTY chunk emitted by the target child. Used by
// WaitForPattern to avoid 50ms-tick polling of the entire ring/grid.
type chunkNotifier struct {
childID string
fired chan struct{}
}
func newChunkNotifier(childID string) *chunkNotifier {
return &chunkNotifier{childID: childID, fired: make(chan struct{}, 1)}
}
func (n *chunkNotifier) OnChildSpawned(*Child) {}
func (n *chunkNotifier) OnChildExited(c *Child) {
if c.ID != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
if id != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (n *chunkNotifier) OnChildStateChanged(string, IdleState) {}
func (n *chunkNotifier) OnChildClosed(string) {}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
if c == nil {
return nil, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
src := c.Ports()
out := make([]mcp.PortSighting, 0, len(src))
for _, p := range src {
out = append(out, mcp.PortSighting(p))
}
return out, nil
}
// ───────────────────────────────────────────────────────────────────
// I/O
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendInputResult, error) {
c := h.sess.FindChild(args.ProcessID)
if c == nil {
return mcp.SendInputResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
if !c.IsLive() {
return mcp.SendInputResult{}, fmt.Errorf("process %q is %s", args.ProcessID, c.Status())
}
payload, err := encodeInput(args)
if err != nil {
return mcp.SendInputResult{}, err
}
if err := c.InjectAsOrchestrator(payload); err != nil {
return mcp.SendInputResult{}, err
}
res := mcp.SendInputResult{OK: true}
if args.WaitMS > 0 {
mode := args.TailMode
if mode == "" {
mode = "stream"
}
if mode != "none" {
time.Sleep(time.Duration(args.WaitMS) * time.Millisecond)
tail, err := h.GetProcessOutput(callerID, args.ProcessID, mode, 0)
if err == nil {
res.Tail = &tail
}
}
}
return res, nil
}
func encodeInput(args mcp.SendInputArgs) ([]byte, error) {
switch args.Kind {
case "", "text":
submit := true
if args.Submit != nil {
submit = *args.Submit
}
out := []byte(args.Text)
if submit {
// CR (`\r`) is what every terminal emits for Enter in raw
// mode, and what TUI agents (claude/codex/…) bind to
// "submit". Sending `\n` here used to land as a literal
// newline inside their textareas, leaving the message
// composed but not sent.
out = append(out, '\r')
}
return out, nil
case "paste":
// Bracketed paste sentinels — SPEC §7. Always sent; emulator
// handles falling back when the target hasn't enabled bracketed
// paste mode (the sentinels just print).
return []byte("\x1b[200~" + args.Text + "\x1b[201~"), nil
case "key":
return encodeKey(args.Key)
}
return nil, mcp.Errorf(mcp.ErrorKindInvalidArgs, "send_input: unknown kind %q", args.Kind)
}
// encodeKey maps a SPEC §7 named key to bytes. We use legacy xterm
// escapes here; the emulator's key encoder is concerned with what
// arrives FROM the user, not bytes sent TO the child. Kitty keyboard
// protocol support is a future refinement.
func encodeKey(key string) ([]byte, error) {
switch key {
case "enter":
return []byte{'\r'}, nil
case "tab":
return []byte{'\t'}, nil
case "escape":
return []byte{0x1b}, nil
case "backspace":
return []byte{0x7f}, nil
case "ctrl-c":
return []byte{0x03}, nil
case "ctrl-d":
return []byte{0x04}, nil
case "up":
return []byte("\x1b[A"), nil
case "down":
return []byte("\x1b[B"), nil
case "right":
return []byte("\x1b[C"), nil
case "left":
return []byte("\x1b[D"), nil
case "home":
return []byte("\x1b[H"), nil
case "end":
return []byte("\x1b[F"), nil
case "page-up":
return []byte("\x1b[5~"), nil
case "page-down":
return []byte("\x1b[6~"), nil
case "f1":
return []byte("\x1bOP"), nil
case "f2":
return []byte("\x1bOQ"), nil
case "f3":
return []byte("\x1bOR"), nil
case "f4":
return []byte("\x1bOS"), nil
case "f5":
return []byte("\x1b[15~"), nil
case "f6":
return []byte("\x1b[17~"), nil
case "f7":
return []byte("\x1b[18~"), nil
case "f8":
return []byte("\x1b[19~"), nil
case "f9":
return []byte("\x1b[20~"), nil
case "f10":
return []byte("\x1b[21~"), nil
case "f11":
return []byte("\x1b[23~"), nil
case "f12":
return []byte("\x1b[24~"), nil
}
return nil, mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown key %q", key)
}
// ───────────────────────────────────────────────────────────────────
// Coordination
// ───────────────────────────────────────────────────────────────────
// SendMessage delivers a tagged message into the target's PTY.
// Direction is inferred from the caller↔target relationship (SPEC §7
// send_message): parent→child → `[orchestrator]`; child→parent →
// `[sub-agent:<caller>]`; everything else (siblings, unrelated) →
// `not_related`.
func (h *toolHost) SendMessage(callerID, targetID, message string) error {
target := h.sess.FindChild(targetID)
if target == nil {
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", targetID)
}
caller := h.sess.FindChild(callerID)
line, err := classifySendMessage(caller, target, callerID, message)
if err != nil {
return err
}
return target.InjectAsOrchestrator([]byte(line))
}
// classifySendMessage is the pure routing-decision helper extracted
// from SendMessage so unit tests can exercise the direction inference
// without spinning up real PTYs.
//
// The caller pointer may be nil — that's the case when the request
// arrives over an MCP connection without a resolved patterm identity
// (a top-level tool client). In that case we treat the caller as an
// implicit orchestrator and accept the message if the target is a
// top-level process.
func classifySendMessage(caller, target *Child, callerID, message string) (string, error) {
if target.ID == callerID {
return "", mcp.Errorf(mcp.ErrorKindNotRelated, "send_message: cannot send to self")
}
if caller != nil && target.ParentID == caller.ID {
return "[orchestrator] " + message + "\r", nil
}
if caller != nil && caller.ParentID == target.ID {
return fmt.Sprintf("[sub-agent:%s] %s\r", caller.DisplayName(), message), nil
}
if caller == nil && target.ParentID == "" {
return "[orchestrator] " + message + "\r", nil
}
return "", mcp.Errorf(mcp.ErrorKindNotRelated, "send_message: %q is neither parent nor child of caller (siblings must route through the parent in v1)", target.ID)
}
func (h *toolHost) RequestHumanAttention(callerID, processID, reason string) error {
if h.attention != nil {
h.attention.notifyAttention(processID, reason)
}
return nil
}
// TimerWait is the legacy fire-and-forget delay timer. It now wraps
// TimerSet with an empty body — defaultFireFn substitutes the
// "[system] Your timer […] has completed." line so behaviour matches
// the original API. New callers should use timer_set with an explicit
// body.
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
return h.timers.TimerSet(callerID, "", label, seconds)
}
func (h *toolHost) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
id, err := h.timers.TimerSet(owner, args.Body, args.Label, args.Seconds)
if err != nil {
return mcp.TimerHandle{}, err
}
return mcp.TimerHandle{ID: id}, nil
}
func (h *toolHost) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
return h.timers.TimerFireWhenIdleAny(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
}
func (h *toolHost) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
return h.timers.TimerFireWhenIdleAll(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
}
// resolveTimerOwner picks the owner process for a timer. Explicit
// owner_process_id wins; otherwise the caller's own id is used.
// Top-level MCP clients (no callerID) must provide owner_process_id
// explicitly.
func resolveTimerOwner(callerID, explicit string) string {
if explicit != "" {
return explicit
}
return callerID
}
func (h *toolHost) TimerCancel(callerID, id string) error {
return h.timers.TimerCancel(callerID, id)
}
func (h *toolHost) TimerPause(callerID, id string) error {
return h.timers.TimerPause(callerID, id)
}
func (h *toolHost) TimerResume(callerID, id string) error {
return h.timers.TimerResume(callerID, id)
}
func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
return h.timers.TimerList(callerID), nil
}
// ───────────────────────────────────────────────────────────────────
// Scratchpads / Meta
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadRead(name string) (string, string, error) {
return h.pads.Read(name)
}
func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
rev, err := h.pads.Write(name, content, expectedRevision)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return rev, err
}
func (h *toolHost) ScratchpadAppend(name, content string) error {
err := h.pads.Append(name, content)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return err
}
func (h *toolHost) ScratchpadDelete(name string) error {
err := h.pads.Delete(name)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return err
}
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
w := mcp.WhoAmI{
ProcessID: callerID,
Role: h.CallerRole(callerID),
Project: mcp.ProjectMeta{
Path: h.sess.projectDir,
Key: h.sess.projectKey,
},
AvailableTools: availableToolsForRole(h.CallerRole(callerID)),
}
if c := h.sess.FindChild(callerID); c != nil {
w.Name = c.DisplayName()
w.ParentProcessID = c.ParentID
}
return w
}
func (h *toolHost) Help(callerID, topic string) mcp.HelpResponse {
return helpFor(topic)
}
// ───────────────────────────────────────────────────────────────────
// Internal helpers
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) processInfoOf(c *Child) mcp.ProcessInfo {
info := mcp.ProcessInfo{
ID: c.ID,
Name: c.DisplayName(),
Kind: string(c.Kind),
Status: string(c.Status()),
ParentProcessID: c.ParentID,
IdleMS: c.IdleMS(),
}
if !c.IsLive() && c.Status() != StatusStopped {
ec := c.ExitCode()
info.ExitCode = &ec
}
if c.Kind == KindCommand && c.PresetRef != "" {
t := h.trust.IsTrusted(c.PresetRef)
info.Trusted = &t
}
if s := c.IdleState(); s != StateUnknown {
info.IdleState = string(s)
info.IdleReason = c.IdleReason()
}
return info
}
func (h *toolHost) chromeHintsFor(presetName string) []string {
if presetName == "" {
return nil
}
for _, p := range h.presets.Agents {
if p.Name == presetName {
return p.ChromeTrimHints
}
}
return nil
}
func (h *toolHost) commandPresetByName(name string) *preset.Preset {
for _, p := range h.presets.Processes {
if p.Name == name {
return p
}
}
return nil
}
func (h *toolHost) terminalName(name string) string {
if name != "" {
return name
}
return "terminal"
}
func (h *toolHost) mergeEnv(extra map[string]string) []string {
if len(extra) == 0 {
return nil
}
env := h.sess.ChildEnv()
for k, v := range extra {
env = append(env, k+"="+v)
}
return env
}
func (h *toolHost) recordStart(id string) {
h.startedAtMu.Lock()
defer h.startedAtMu.Unlock()
h.startedAt[id] = time.Now()
}
func (h *toolHost) startedAtOf(id string) time.Time {
h.startedAtMu.Lock()
defer h.startedAtMu.Unlock()
return h.startedAt[id]
}
func (h *toolHost) askForTrust(callerID, presetName, reason string) {
if h.prompter == nil {
return
}
h.prompter.promptTrust(callerID, presetName, reason)
}
// wrapSubAgentPrompt prepends a one-line orientation block to the
// caller-supplied agent_instructions. patterm injects nothing on its
// own (SPEC §7), but vendor TUIs that learn their role purely from
// their first turn need to be told they're a sub-agent — otherwise
// they finish without reporting back to the parent or cleaning up
// processes/scratchpads they spawned. The block is single-line on
// purpose: writeInput splits on CR/LF, so any embedded newline would
// submit prematurely.
func wrapSubAgentPrompt(instructions string, hasParent bool) string {
if !hasParent {
return instructions
}
if instructions == "" {
return ""
}
const preface = "[system: you are a patterm sub-agent. When your work is done, call send_message to your parent (use whoami to get parent_process_id) with a summary, and close_process / scratchpad cleanup anything you created. See help('conventions').] "
return preface + instructions
}
// applyChromeTrim deletes lines matching any of the given regexes.
// SPEC §10 chrome_trim_hints.
func applyChromeTrim(txt string, hints []string) string {
if len(hints) == 0 {
return txt
}
res := make([]*regexp.Regexp, 0, len(hints))
for _, h := range hints {
re, err := regexp.Compile(h)
if err != nil {
continue
}
res = append(res, re)
}
if len(res) == 0 {
return txt
}
out := make([]string, 0, 64)
for _, line := range strings.Split(txt, "\n") {
drop := false
for _, re := range res {
if re.MatchString(line) {
drop = true
break
}
}
if !drop {
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
func activeScreenName(s pkgvt.Screen) string {
switch s {
case pkgvt.ScreenAlternate:
return "alternate"
default:
return "main"
}
}
// ansiRegexp strips CSI escape sequences and common single-character
// controls (BEL, OSC terminators) from the stream. The vt emulator
// already handles full rendering for grid mode; this is only for
// stream-mode ANSI-stripped output.
var ansiRegexp = regexp.MustCompile(`\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
func normalizeGridText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
pendingBlank := false
for _, line := range lines {
line = strings.TrimRightFunc(line, unicode.IsSpace)
if line == "" {
if len(out) > 0 {
pendingBlank = true
}
continue
}
if pendingBlank {
out = append(out, "")
pendingBlank = false
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a
// pattern match (WaitForPattern scrollback). Recognises the same
// shapes the regex did:
// - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR)
// - `\x1b<final-byte>` for `@..._` (one-byte escapes)
// - `\x07` (BEL)
//
// The dst slice is reused if cap is sufficient; the returned slice
// is what callers should use.
func stripANSIBytes(dst, src []byte) []byte {
if cap(dst) < len(src) {
dst = make([]byte, 0, len(src))
} else {
dst = dst[:0]
}
for i := 0; i < len(src); {
b := src[i]
if b == 0x07 {
i++
continue
}
if b != 0x1b {
dst = append(dst, b)
i++
continue
}
// ESC-led sequence.
if i+1 >= len(src) {
// Stranded ESC at end of buffer — drop it.
i++
continue
}
next := src[i+1]
if next != '[' {
// One-byte ESC sequence (`\x1b<final>` where final is
// `@..._` per the regex; we drop anything that follows).
if next >= 0x40 && next <= 0x5f {
i += 2
continue
}
// Anything else after ESC: drop the ESC, keep walking.
i++
continue
}
// CSI: parameters [0x30..0x3f]*, intermediate [0x20..0x2f]*,
// final [0x40..0x7e].
j := i + 2
for j < len(src) && src[j] >= 0x30 && src[j] <= 0x3f {
j++
}
for j < len(src) && src[j] >= 0x20 && src[j] <= 0x2f {
j++
}
if j < len(src) && src[j] >= 0x40 && src[j] <= 0x7e {
i = j + 1
continue
}
// Incomplete CSI — the regex form falls back to its
// `\x1b<final>` rule and matches `\x1b[` (`[` is 0x5b, inside
// 0x40..0x5f), consuming the two-byte prefix and leaving the
// pending params/intermediate bytes intact. Match that.
i += 2
}
return dst
}
// availableToolsForRole — SPEC §7 whoami exposes the list a caller can
// invoke from its current role. Sub-agents lose `spawn_agent` (§8
// two-level-tree rule).
func availableToolsForRole(role mcp.CallerRole) []string {
tools := []string{
"spawn_process", "start_process", "restart_process", "stop_process",
"close_process", "rename_process", "select_process",
"list_processes", "get_process_status", "get_project_status",
"get_process_output", "get_process_raw_output", "search_output",
"wait_for_pattern", "get_process_ports",
"send_input", "send_message", "request_human_attention",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete",
"whoami", "help",
}
if role == mcp.RoleOrchestrator {
tools = append([]string{"spawn_agent"}, tools...)
}
return tools
}
// helpFor — SPEC §7 help. Topic content is intentionally short; the
// goal is orientation, not full documentation.
func helpFor(topic string) mcp.HelpResponse {
switch topic {
case "", "topics":
return mcp.HelpResponse{
Topic: "topics",
Content: "Available topics: spawning, lifecycle, inspection, io, coordination, " +
"scratchpads, timers, readiness, permissions, conventions, topics. " +
"Call help(topic) for guidance. Call whoami for your role and the " +
"complete tool list available to you.",
}
case "spawning":
return mcp.HelpResponse{
Topic: "spawning",
Content: "spawn_agent launches another vendor LLM CLI as a sub-agent (orchestrator only). spawn_process(kind: command, preset: …) starts a stored command; spawn_process(kind: terminal) opens a shell. Command presets need trust the first time — you'll get needs_trust until the human accepts. ANTI-PATTERNS: do not shell out to `claude` / `codex` / `opencode` (or any other agent CLI) yourself, and do not pipe JSON-RPC into patterm's Unix socket via perl / nc / socat / curl. Either path bypasses caller-identity and the new agent reads back as a stray top-level tab instead of your child — call spawn_agent through the MCP transport you were initialised on. Whatever you spawn is yours to clean up — see help('lifecycle').",
RelatedTools: []string{"spawn_agent", "spawn_process", "start_process", "restart_process", "close_process"},
}
case "lifecycle":
return mcp.HelpResponse{
Topic: "lifecycle",
Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.",
RelatedTools: []string{"close_process", "stop_process", "start_process", "list_processes", "get_process_status"},
}
case "inspection":
return mcp.HelpResponse{
Topic: "inspection",
Content: "get_process_output gives you the visible pane (grid mode) or a byte slice from since_offset (stream mode). list_processes is for the whole session. get_project_status batches everything you need to orient yourself.",
RelatedTools: []string{"list_processes", "get_process_status", "get_process_output", "search_output", "wait_for_pattern", "get_project_status"},
}
case "io":
return mcp.HelpResponse{
Topic: "io",
Content: "send_input with kind=text submits a line (set submit=false to omit Enter). kind=key uses named keys (enter, tab, escape, ctrl-c, arrows, …). kind=paste wraps the text in bracketed-paste sentinels for multi-line content. Set wait_ms+tail_mode to read back the tail after sending.",
RelatedTools: []string{"send_input"},
}
case "coordination":
return mcp.HelpResponse{
Topic: "coordination",
Content: "send_message tags the message with the caller's role (parent → [orchestrator], child → [sub-agent:<name>]). Siblings must route through their parent. request_human_attention raises a UI notification when you can't safely decide.\n\n" +
"Reply routing: a sub-agent's reply to your send_message lands in YOUR pane tagged `[sub-agent:<name>]`, not in the sub-agent's output. Anti-pattern: `wait_for_pattern(sub_agent, …)` to wait for a reply — the sub-agent is already idle, its output won't change, and the call spins to timeout. Pattern: send_message → timer_fire_when_idle_any([sub_agent_id], body=\"[system] sub-agent finished\") → when the timer fires, the reply is already queued as your next user turn (or visible via get_process_output on your own pane).",
RelatedTools: []string{"send_message", "request_human_attention", "timer_fire_when_idle_any", "timer_fire_when_idle_all"},
}
case "scratchpads":
return mcp.HelpResponse{
Topic: "scratchpads",
Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional; delete removes a pad by name.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete"},
}
case "timers":
return mcp.HelpResponse{
Topic: "timers",
Content: "Timers fire by injecting your chosen body (or a default `[system] Your timer […] has completed.` line) back into your pane as a fresh user turn. Use them instead of sleeping in your own process. " +
"timer_wait / timer_set schedule a delay timer (timer_set lets you set body+label). " +
"timer_fire_when_idle_any fires when any watched process becomes idle (already-idle watchers are excluded from the baseline). " +
"timer_fire_when_idle_all fires when every watched process is idle; if all are idle at registration the response is already_satisfied with no pending timer. " +
"timer_cancel / timer_pause / timer_resume manage outstanding timers; resume re-checks idle conditions in case a watcher went idle while paused. " +
"timer_list shows your pending and paused timers.",
RelatedTools: []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
},
}
case "readiness":
return mcp.HelpResponse{
Topic: "readiness",
Content: "A pane is 'idle' once nothing has been written to its PTY for ~1s (SPEC §11). Treat idle as a signal to read, not a guarantee of completion.\n\n" +
"Waiting for a sub-agent's reply (canonical pattern):\n" +
" 1. send_message(sub_agent_id, request)\n" +
" 2. timer_fire_when_idle_any(watched=[sub_agent_id], body=\"[system] sub-agent done\")\n" +
" 3. When the timer fires you re-enter as a fresh user turn; the sub-agent's reply is already in your own pane tagged `[sub-agent:<name>]` (read via get_process_output on yourself if you need it explicitly).\n\n" +
"wait_for_pattern is for waiting on text a process emits in its OWN output (a shell prompt, a build's \"tests passed\" line). It does NOT see send_message replies, because those land in the caller's pane, not the target's — calling wait_for_pattern on a sub-agent to wait for its reply deadlocks until timeout.",
RelatedTools: []string{"wait_for_pattern", "get_process_status", "timer_fire_when_idle_any", "send_message"},
}
case "permissions":
return mcp.HelpResponse{
Topic: "permissions",
Content: "Sub-agents are launched with vendor permissions on — drive their confirmation prompts via get_process_output + send_input. When you can't safely decide, call request_human_attention.",
RelatedTools: []string{"get_process_output", "send_input", "request_human_attention"},
}
case "conventions":
return mcp.HelpResponse{
Topic: "conventions",
Content: "patterm tags messages in your input so you can tell who's writing:\n" +
" [orchestrator] msg — from your parent\n" +
" [sub-agent:name] msg — from one of your children\n" +
" [system] msg — patterm itself (timers, lifecycle)\n" +
" no tag — the human typed into the pane",
}
}
return mcp.HelpResponse{Topic: topic, Content: fmt.Sprintf("unknown topic %q — try help('topics')", topic)}
}