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.
932 lines
30 KiB
Go
932 lines
30 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/harrybrwn/patterm/internal/mcp"
|
|
"github.com/harrybrwn/patterm/internal/preset"
|
|
"github.com/harrybrwn/patterm/internal/scratchpad"
|
|
"github.com/harrybrwn/patterm/internal/trust"
|
|
pkgvt "github.com/harrybrwn/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)
|
|
}
|
|
|
|
// 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
|
|
|
|
timersMu sync.Mutex
|
|
nextTimer int
|
|
}
|
|
|
|
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
|
|
return &toolHost{
|
|
sess: sess,
|
|
pads: pads,
|
|
launcher: launcher,
|
|
presets: presets,
|
|
trust: tr,
|
|
defaultCol: cols,
|
|
defaultRow: rows,
|
|
startedAt: make(map[string]time.Time),
|
|
}
|
|
}
|
|
|
|
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("unknown_agent", "unknown agent preset %q", args.Agent)
|
|
}
|
|
display := args.Name
|
|
if display == "" {
|
|
display = args.Agent
|
|
}
|
|
c, err := h.launcher.LaunchAgent(p, display, args.AgentInstructions, 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("invalid_kind", "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("needs_trust", "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("not_found", "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("invalid_args", "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("not_found", "no such process %q", processID)
|
|
}
|
|
if c.Kind != KindCommand {
|
|
return mcp.ProcessInfo{}, mcp.Errorf("wrong_kind", "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("needs_trust", "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("not_found", "no such process %q", processID)
|
|
}
|
|
if c.Kind != KindCommand && !c.IsLive() {
|
|
return mcp.ProcessInfo{}, mcp.Errorf("wrong_kind", "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("needs_trust", "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("not_found", "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("not_found", "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("not_found", "no such process %q", processID)
|
|
}
|
|
if name == "" {
|
|
return mcp.Errorf("invalid_args", "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("not_found", "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("not_found", "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("not_found", "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 = txt
|
|
return out, nil
|
|
case "stream":
|
|
b, end := c.StreamRead(sinceOffset)
|
|
out.Content = stripANSI(string(b))
|
|
out.NewOffset = end
|
|
return out, nil
|
|
default:
|
|
return mcp.ProcessOutput{}, mcp.Errorf("invalid_args", "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("not_found", "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("not_found", "no such process %q", processID)
|
|
}
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return mcp.SearchResult{}, mcp.Errorf("invalid_args", "regex: %v", err)
|
|
}
|
|
b, _ := c.StreamRead(0)
|
|
text := string(b)
|
|
if kind == "rendered" {
|
|
text = stripANSI(text)
|
|
}
|
|
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("not_found", "no such process %q", processID)
|
|
}
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return false, "", mcp.Errorf("invalid_args", "regex: %v", err)
|
|
}
|
|
if scope == "" {
|
|
scope = "grid"
|
|
}
|
|
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
|
|
tick := time.NewTicker(50 * time.Millisecond)
|
|
defer tick.Stop()
|
|
for {
|
|
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 = stripANSI(string(b))
|
|
default:
|
|
return false, "", mcp.Errorf("invalid_args", "unknown scope %q (want grid|scrollback)", scope)
|
|
}
|
|
if m := re.FindString(text); m != "" {
|
|
return true, m, nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return false, "", nil
|
|
}
|
|
<-tick.C
|
|
if !c.IsLive() && c.Status() != StatusStopped {
|
|
return false, "", nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
|
|
c := h.sess.FindChild(processID)
|
|
if c == nil {
|
|
return nil, mcp.Errorf("not_found", "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("not_found", "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 {
|
|
out = append(out, '\n')
|
|
}
|
|
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("invalid_args", "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("invalid_args", "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("not_found", "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("not_related", "send_message: cannot send to self")
|
|
}
|
|
if caller != nil && target.ParentID == caller.ID {
|
|
return "[orchestrator] " + message + "\n", nil
|
|
}
|
|
if caller != nil && caller.ParentID == target.ID {
|
|
return fmt.Sprintf("[sub-agent:%s] %s\n", caller.DisplayName(), message), nil
|
|
}
|
|
if caller == nil && target.ParentID == "" {
|
|
return "[orchestrator] " + message + "\n", nil
|
|
}
|
|
return "", mcp.Errorf("not_related", "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
|
|
}
|
|
|
|
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
|
|
caller := h.sess.FindChild(callerID)
|
|
if caller == nil {
|
|
return "", mcp.Errorf("not_found", "caller %q not known to patterm", callerID)
|
|
}
|
|
h.timersMu.Lock()
|
|
h.nextTimer++
|
|
id := fmt.Sprintf("t%d", h.nextTimer)
|
|
h.timersMu.Unlock()
|
|
if label == "" {
|
|
label = id
|
|
}
|
|
go func() {
|
|
time.Sleep(time.Duration(seconds * float64(time.Second)))
|
|
if !caller.IsLive() {
|
|
return
|
|
}
|
|
line := fmt.Sprintf("[system] Your timer [%s] has completed.\n", label)
|
|
_ = caller.InjectAsOrchestrator([]byte(line))
|
|
}()
|
|
return id, nil
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────────
|
|
// Scratchpads / Meta
|
|
// ───────────────────────────────────────────────────────────────────
|
|
|
|
func (h *toolHost) Scratchpads() *scratchpad.Store { return h.pads }
|
|
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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, "")
|
|
}
|
|
|
|
// 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",
|
|
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append",
|
|
"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, 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.",
|
|
RelatedTools: []string{"spawn_agent", "spawn_process", "start_process", "restart_process"},
|
|
}
|
|
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.",
|
|
RelatedTools: []string{"send_message", "request_human_attention"},
|
|
}
|
|
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.",
|
|
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"},
|
|
}
|
|
case "timers":
|
|
return mcp.HelpResponse{
|
|
Topic: "timers",
|
|
Content: "timer_wait returns a timer_id immediately and injects `[system] Your timer [<label>] has completed.` into your pane when it fires. Use it instead of sleeping in your own process.",
|
|
RelatedTools: []string{"timer_wait"},
|
|
}
|
|
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. wait_for_pattern lets you wait on a known terminal marker for stronger evidence.",
|
|
RelatedTools: []string{"wait_for_pattern", "get_process_status"},
|
|
}
|
|
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)}
|
|
}
|