Stale timer bodies were re-delivered to the orchestrator pane after the parent had already processed the sub-agent's reply and called close_process. The timer registry held no link to the child lifecycle, so timers owned by or watching the closed child lingered until something triggered a fire — e.g. a trailing classifier tick for the now-removed child. Add an OnChildClosed hook to ChildEventListener, emit it from Session.Close (and the terminal-corpse path in reapChild), and have the timer manager prune the registry: cancel timers owned by the closed child; remove the closed child from each timer's watched list (cancel the timer outright when watched empties). Natural exit deliberately does not route through this hook — the classifier already emits an idle transition on exit which delivers any legitimate "fire when sub-agent finishes" semantics exactly once; cancelling on exit would swallow that.
1196 lines
42 KiB
Go
1196 lines
42 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"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 = 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) 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, "")
|
|
}
|
|
|
|
// 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",
|
|
"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.",
|
|
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"},
|
|
}
|
|
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)}
|
|
}
|