package app import ( "fmt" "regexp" "strings" "sync" "syscall" "time" "github.com/harrybrwn/patterm/internal/mcp" "github.com/harrybrwn/patterm/internal/policy" "github.com/harrybrwn/patterm/internal/preset" "github.com/harrybrwn/patterm/internal/scratchpad" ) // attentionSink is implemented by uiState to surface // request_human_attention notifications. type attentionSink interface { notifyAttention(childID, reason string) } // toolHost adapts the running session + scratchpad 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 policy *policy.Policy sizeMu sync.Mutex defaultRow uint16 defaultCol uint16 attention attentionSink // timersMu guards timers. timersMu sync.Mutex nextTimer int } 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 } func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, pol *policy.Policy, cols, rows uint16) *toolHost { return &toolHost{ sess: sess, pads: pads, launcher: launcher, presets: presets, policy: pol, defaultCol: cols, defaultRow: rows, } } // PolicyCheck — SPEC §9 hook. Lets an orchestrator ask whether a // prompt-looking string is safe to auto-answer. func (h *toolHost) PolicyCheck(prompt string) string { if h.policy == nil { return string(policy.Unknown) } return string(h.policy.Should(prompt)) } // Children — SPEC §7 list_children. The idle_ms field gives the // orchestrator the SPEC §11 done-signal without needing to poll bytes. func (h *toolHost) Children() []mcp.ChildInfo { cs := h.sess.Children() out := make([]mcp.ChildInfo, 0, len(cs)) for _, c := range cs { out = append(out, mcp.ChildInfo{ ID: c.ID, Name: c.Name, Type: string(c.Kind), Status: string(c.Status()), ExitCode: c.ExitCode(), IdleMS: c.IdleMS(), ParentID: c.ParentID, }) } return out } func (h *toolHost) Spawn(callerID, name string, argv []string, shell bool) (mcp.ChildInfo, error) { if shell && len(argv) > 0 { argv = []string{"sh", "-lc", strings.Join(argv, " ")} } parent := callerID cols, rows := h.size() c, err := h.sess.Spawn(name, KindProcess, argv, nil, cols, rows, parent) if err != nil { return mcp.ChildInfo{}, err } return mcp.ChildInfo{ ID: c.ID, Name: c.Name, Type: string(c.Kind), Status: string(c.Status()), ParentID: c.ParentID, }, nil } func (h *toolHost) SpawnAgent(callerID, presetName, displayName, initialPrompt string) (mcp.ChildInfo, error) { var p *preset.Preset for _, ap := range h.presets.Agents { if ap.Name == presetName { p = ap break } } if p == nil { return mcp.ChildInfo{}, fmt.Errorf("unknown agent preset %q", presetName) } if displayName == "" { displayName = presetName } c, err := h.launcher.LaunchAgent(p, displayName, initialPrompt, callerID) if err != nil { return mcp.ChildInfo{}, err } return mcp.ChildInfo{ ID: c.ID, Name: c.Name, Type: string(c.Kind), Status: string(c.Status()), ParentID: c.ParentID, }, nil } // ReadOutput — SPEC §7. Grid uses the emulator's PlainText; stream uses // the per-child ring buffer. For grid reads on agents we apply the // preset's chrome_trim_hints (SPEC §10) so banners/input-box noise // doesn't pollute orchestrator parsing. func (h *toolHost) ReadOutput(callerID, childID, mode string, sinceOffset int) (string, int, error) { c := h.sess.FindChild(childID) if c == nil { return "", 0, fmt.Errorf("no such child %q", childID) } switch mode { case "grid": txt, err := c.em.PlainText() if err != nil { return "", 0, err } if c.Kind == KindAgent { txt = applyChromeTrim(txt, h.chromeHintsFor(c.Name)) } return txt, 0, nil case "stream": b, off := c.StreamRead(int64(sinceOffset)) return string(b), int(off), nil default: return "", 0, fmt.Errorf("unknown read_output mode %q", mode) } } func (h *toolHost) chromeHintsFor(presetName string) []string { for _, p := range h.presets.Agents { if p.Name == presetName { return p.ChromeTrimHints } } return nil } // applyChromeTrim deletes every line that matches any of the given // regexes. Compiled regexes are cached per call; the agent preset list // is small enough that recompilation cost is negligible. 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 (h *toolHost) SendInput(callerID, childID string, payload []byte, appendNewline bool) error { if appendNewline { payload = append(payload, '\n') } c := h.sess.FindChild(childID) if c == nil { return fmt.Errorf("no such child %q", childID) } if c.Status() != StatusRunning { return fmt.Errorf("child %q is %s", childID, c.Status()) } return c.InjectAsOrchestrator(payload) } func (h *toolHost) Kill(callerID, childID string, sig syscall.Signal) error { return h.sess.Kill(childID, sig) } // SendMessageTo — SPEC §7 + §8. Injects "[orchestrator] \n" into // the target's PTY. func (h *toolHost) SendMessageTo(callerID, targetID, message string) error { target := h.sess.FindChild(targetID) if target == nil { return fmt.Errorf("no such child %q", targetID) } line := "[orchestrator] " + message + "\n" return target.InjectAsOrchestrator([]byte(line)) } // ReportToParent — SPEC §8. Injects "[sub-agent:] \n" into // the calling agent's parent pane. func (h *toolHost) ReportToParent(callerID, message string) error { caller := h.sess.FindChild(callerID) if caller == nil { return fmt.Errorf("caller %q not known to patterm", callerID) } if caller.ParentID == "" { return fmt.Errorf("caller %q has no parent", callerID) } parent := h.sess.FindChild(caller.ParentID) if parent == nil { return fmt.Errorf("parent %q gone", caller.ParentID) } line := fmt.Sprintf("[sub-agent:%s] %s\n", caller.Name, message) return parent.InjectAsOrchestrator([]byte(line)) } // TimerWait — SPEC §7. Returns immediately with a timer_id. After // seconds elapse, injects "[system] Your timer [