Initial patterm project

This commit is contained in:
2026-05-14 13:37:20 +01:00
commit 69ef09aac4
40 changed files with 6521 additions and 0 deletions

335
internal/app/host.go Normal file
View File

@@ -0,0 +1,335 @@
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
}