Initial patterm project
This commit is contained in:
335
internal/app/host.go
Normal file
335
internal/app/host.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user