package app import ( "fmt" "regexp" "strings" "sync" "syscall" "time" "github.com/harrybrwn/patterm/internal/mcp" "github.com/harrybrwn/patterm/internal/preset" "github.com/harrybrwn/patterm/internal/scratchpad" "github.com/harrybrwn/patterm/internal/trust" pkgvt "github.com/harrybrwn/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 { out = append(out, '\n') } 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:]`; 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 + "\n", nil } if caller != nil && caller.ParentID == target.ID { return fmt.Sprintf("[sub-agent:%s] %s\n", caller.DisplayName(), message), nil } if caller == nil && target.ParentID == "" { return "[orchestrator] " + message + "\n", 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.\n", 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:]). 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 [