336 lines
8.8 KiB
Go
336 lines
8.8 KiB
Go
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] <msg>\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:<name>] <msg>\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 [<label>] has completed.\n"
|
|
// into the calling agent's pane.
|
|
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
|
|
caller := h.sess.FindChild(callerID)
|
|
if caller == nil {
|
|
return "", fmt.Errorf("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.Status() != StatusRunning {
|
|
return
|
|
}
|
|
line := fmt.Sprintf("[system] Your timer [%s] has completed.\n", label)
|
|
_ = caller.InjectAsOrchestrator([]byte(line))
|
|
}()
|
|
return id, nil
|
|
}
|
|
|
|
// WaitForPattern — SPEC §7. Polls the child's plain-text grid at ~50ms
|
|
// until the regex matches or the timeout expires.
|
|
func (h *toolHost) WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (bool, string, error) {
|
|
c := h.sess.FindChild(childID)
|
|
if c == nil {
|
|
return false, "", fmt.Errorf("no such child %q", childID)
|
|
}
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return false, "", fmt.Errorf("regex: %w", err)
|
|
}
|
|
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
|
|
tick := time.NewTicker(50 * time.Millisecond)
|
|
defer tick.Stop()
|
|
for {
|
|
txt, err := c.em.PlainText()
|
|
if err == nil {
|
|
if m := re.FindString(txt); m != "" {
|
|
return true, m, nil
|
|
}
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return false, "", nil
|
|
}
|
|
<-tick.C
|
|
if c.Status() != StatusRunning {
|
|
return false, "", nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *toolHost) RequestHumanAttention(callerID, childID, reason string) error {
|
|
if h.attention != nil {
|
|
h.attention.notifyAttention(childID, reason)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *toolHost) Scratchpads() *scratchpad.Store {
|
|
return h.pads
|
|
}
|
|
|
|
// ResolveCallerIdentity maps the per-spawn identity token back to a
|
|
// child ID so the tools above can use it as a parent pointer / inject
|
|
// target.
|
|
func (h *toolHost) ResolveCallerIdentity(identity string) string {
|
|
c := h.sess.FindChildByIdentity(identity)
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return c.ID
|
|
}
|