Files
patterm/internal/app/host.go
Harry Bayliss 3622c41fd0 Land staged session/MCP/chrome work + sidebar clear-J fix
This batches the in-flight [Unreleased] block from CHANGELOG.md into a
single commit. Highlights:

- Real MCP protocol layer (initialize / tools/list / tools/call) so
  vendor MCP clients can complete the handshake against the per-PID
  socket. Legacy direct-dispatch preserved for the harness.
- New mcp_injection kinds — cli_override for codex, config_env for
  opencode — joining the existing env-var and config_file paths so
  patterm can slot into more agents without touching their real
  config or auth.
- Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab
  process lists, recognised in legacy / kitty CSI u / xterm
  modifyOtherKeys encodings.
- Palette macros (sw / k / sp ) and reordering so open sessions
  surface above spawn-new entries.
- Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe
  on agent spawn, CR-terminated orchestrator injections, and split-
  Enter PTY writes so paste-detecting TUIs see Enter as a key event.

Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion
emits CSI 0 J, which the viewport renderer was forwarding verbatim —
wiping the sidebar to the right of the cursor and leaving the chrome
cache convinced nothing had changed. CSI 0 J and CSI 1 J are now
translated into per-row ECH sequences clamped to the viewport, same
as CSI 2 J and CSI K already were.

Agent guides (CLAUDE.md / AGENTS.md) now spell out the
TODO->CHANGELOG workflow so completed items land in the changelog
rather than as ticked entries left behind in TODO.
2026-05-14 19:09:35 +01:00

937 lines
30 KiB
Go

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)
}
// 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
timersMu sync.Mutex
nextTimer int
}
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
return &toolHost{
sess: sess,
pads: pads,
launcher: launcher,
presets: presets,
trust: tr,
defaultCol: cols,
defaultRow: rows,
startedAt: make(map[string]time.Time),
}
}
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("unknown_agent", "unknown agent preset %q", args.Agent)
}
display := args.Name
if display == "" {
display = args.Agent
}
c, err := h.launcher.LaunchAgent(p, display, args.AgentInstructions, 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("invalid_kind", "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("needs_trust", "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("not_found", "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("invalid_args", "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("not_found", "no such process %q", processID)
}
if c.Kind != KindCommand {
return mcp.ProcessInfo{}, mcp.Errorf("wrong_kind", "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("needs_trust", "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("not_found", "no such process %q", processID)
}
if c.Kind != KindCommand && !c.IsLive() {
return mcp.ProcessInfo{}, mcp.Errorf("wrong_kind", "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("needs_trust", "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("not_found", "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("not_found", "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("not_found", "no such process %q", processID)
}
if name == "" {
return mcp.Errorf("invalid_args", "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("not_found", "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("not_found", "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("not_found", "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 = stripANSI(string(b))
out.NewOffset = end
return out, nil
default:
return mcp.ProcessOutput{}, mcp.Errorf("invalid_args", "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("not_found", "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("not_found", "no such process %q", processID)
}
re, err := regexp.Compile(pattern)
if err != nil {
return mcp.SearchResult{}, mcp.Errorf("invalid_args", "regex: %v", err)
}
b, _ := c.StreamRead(0)
text := string(b)
if kind == "rendered" {
text = stripANSI(text)
}
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("not_found", "no such process %q", processID)
}
re, err := regexp.Compile(pattern)
if err != nil {
return false, "", mcp.Errorf("invalid_args", "regex: %v", err)
}
if scope == "" {
scope = "grid"
}
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
for {
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 = stripANSI(string(b))
default:
return false, "", mcp.Errorf("invalid_args", "unknown scope %q (want grid|scrollback)", scope)
}
if m := re.FindString(text); m != "" {
return true, m, nil
}
if time.Now().After(deadline) {
return false, "", nil
}
<-tick.C
if !c.IsLive() && c.Status() != StatusStopped {
return false, "", nil
}
}
}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
if c == nil {
return nil, mcp.Errorf("not_found", "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("not_found", "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("invalid_args", "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("invalid_args", "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:<caller>]`; 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("not_found", "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("not_related", "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("not_related", "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
}
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
caller := h.sess.FindChild(callerID)
if caller == nil {
return "", mcp.Errorf("not_found", "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.IsLive() {
return
}
line := fmt.Sprintf("[system] Your timer [%s] has completed.\r", label)
_ = caller.InjectAsOrchestrator([]byte(line))
}()
return id, nil
}
// ───────────────────────────────────────────────────────────────────
// Scratchpads / Meta
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) Scratchpads() *scratchpad.Store { return h.pads }
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
}
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)
}
// 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, "")
}
// 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",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append",
"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, 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.",
RelatedTools: []string{"spawn_agent", "spawn_process", "start_process", "restart_process"},
}
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:<name>]). Siblings must route through their parent. request_human_attention raises a UI notification when you can't safely decide.",
RelatedTools: []string{"send_message", "request_human_attention"},
}
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.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"},
}
case "timers":
return mcp.HelpResponse{
Topic: "timers",
Content: "timer_wait returns a timer_id immediately and injects `[system] Your timer [<label>] has completed.` into your pane when it fires. Use it instead of sleeping in your own process.",
RelatedTools: []string{"timer_wait"},
}
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. wait_for_pattern lets you wait on a known terminal marker for stronger evidence.",
RelatedTools: []string{"wait_for_pattern", "get_process_status"},
}
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)}
}