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:]`; 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, "") } // 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[ ` (CSI / SGR) // - `\x1b` 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` 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` 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:]). 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:]`, 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:]` (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)} }