Files
patterm/internal/app/mcp_inject.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

378 lines
12 KiB
Go

package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/hjbdev/patterm/internal/preset"
)
// patternMcpEntryName is the canonical name patterm uses when slotting
// itself into an external MCP config block (codex's mcp_servers,
// opencode's mcp, etc.). Stable on purpose: a single name means
// repeated spawns replace the previous entry instead of accumulating.
const patternMcpEntryName = "patterm"
// mcpConfigMerge prepares a vendored copy of the user's config file
// with patterm's MCP entry merged in, lays it out under a per-spawn
// home directory, and returns the env var assignment the child needs
// (e.g. "CODEX_HOME=/tmp/patterm-mcp-xxx").
//
// patterm never modifies the user's real config file in place. The
// merged copy lives under $XDG_RUNTIME_DIR/patterm/agents/<identity>/
// and is removed when the agent process exits.
func mcpConfigMerge(p *preset.Preset, inj *preset.MCPInjection, identity, bin, socket string) (envAssign, homeDir string, err error) {
// Allow older preset files that pre-date the home_var / home_path /
// format fields by falling back to known defaults for the well-known
// agent config paths.
homeVar, homePath, format := inj.HomeVar, inj.HomePath, strings.ToLower(inj.Format)
if homeVar == "" || homePath == "" || format == "" {
hv, hp, f := inferHomeFromPath(inj.Path)
if homeVar == "" {
homeVar = hv
}
if homePath == "" {
homePath = hp
}
if format == "" {
format = f
}
}
if format == "" {
switch strings.ToLower(filepath.Ext(inj.Path)) {
case ".toml":
format = "toml"
case ".json":
format = "json"
}
}
if homeVar == "" || homePath == "" {
return "", "", fmt.Errorf("preset %s: mcp_injection.config_file requires home_var and home_path (path %q not recognised; add the fields to the preset)", p.Name, inj.Path)
}
if inj.MergeKey == "" {
return "", "", fmt.Errorf("preset %s: mcp_injection.config_file requires merge_key", p.Name)
}
if format == "" {
return "", "", fmt.Errorf("preset %s: cannot infer mcp_injection.format from path %q", p.Name, inj.Path)
}
homeDir, err = mcpRuntimeDir(identity)
if err != nil {
return "", "", err
}
dest := filepath.Join(homeDir, homePath)
if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil {
return "", "", err
}
src := expandUser(inj.Path)
// Mirror the user's real agent-home directory (auth, sessions,
// history, etc.) into the temp home via symlinks so codex / opencode
// still see their credentials and prior state. Only the config file
// itself is replaced with our merged copy.
if err := mirrorAgentHome(filepath.Dir(src), filepath.Dir(dest), filepath.Base(dest)); err != nil {
return "", "", err
}
srcBody, err := os.ReadFile(src)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", "", fmt.Errorf("read %s: %w", src, err)
}
// srcBody stays nil if the user has no existing config — we'll
// write a fresh minimal one with just the patterm entry.
args := []string{"mcp-stdio", "--socket", socket, "--identity", identity}
var merged []byte
switch format {
case "toml":
merged, err = mergeTOMLMCP(srcBody, inj.MergeKey, bin, args)
case "json":
merged, err = mergeJSONMCP(srcBody, inj.MergeKey, bin, args)
default:
err = fmt.Errorf("preset %s: unsupported mcp_injection.format %q", p.Name, format)
}
if err != nil {
return "", "", err
}
if err := os.WriteFile(dest, merged, 0o600); err != nil {
return "", "", err
}
return homeVar + "=" + homeDir, homeDir, nil
}
// mcpConfigEnv reads the user's existing config file, merges patterm's
// MCP entry into it, and returns an env-var assignment (e.g.
// `OPENCODE_CONFIG_CONTENT={...}`) the child can read directly. No
// file is written and XDG_CONFIG_HOME is not touched — the agent's
// auth/state/skill dirs continue to resolve from the user's real
// $HOME exactly as they do without patterm.
func mcpConfigEnv(p *preset.Preset, inj *preset.MCPInjection, identity, bin, socket string) (string, error) {
if inj.Var == "" {
return "", fmt.Errorf("preset %s: mcp_injection.config_env requires var", p.Name)
}
if inj.MergeKey == "" {
return "", fmt.Errorf("preset %s: mcp_injection.config_env requires merge_key", p.Name)
}
format := strings.ToLower(inj.Format)
if format == "" {
switch strings.ToLower(filepath.Ext(inj.Path)) {
case ".toml":
format = "toml"
case ".json":
format = "json"
}
}
if format == "" {
return "", fmt.Errorf("preset %s: cannot infer mcp_injection.format from path %q", p.Name, inj.Path)
}
var srcBody []byte
if inj.Path != "" {
body, err := os.ReadFile(expandUser(inj.Path))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("read %s: %w", inj.Path, err)
}
srcBody = body
}
args := []string{"mcp-stdio", "--socket", socket, "--identity", identity}
var merged []byte
var err error
switch format {
case "toml":
merged, err = mergeTOMLMCP(srcBody, inj.MergeKey, bin, args)
case "json":
merged, err = mergeJSONMCP(srcBody, inj.MergeKey, bin, args)
default:
err = fmt.Errorf("preset %s: unsupported mcp_injection.format %q", p.Name, format)
}
if err != nil {
return "", err
}
return inj.Var + "=" + string(merged), nil
}
// mcpCLIOverrideArgs builds the `-c key=value` argv tail for the
// `cli_override` injection kind. The agent merges these into its
// in-memory config at startup, so there's no filesystem footprint at
// all — codex picks up patterm's MCP server without us touching
// ~/.codex/config.toml or hijacking CODEX_HOME (which would mask
// auth.json and saved sessions).
func mcpCLIOverrideArgs(p *preset.Preset, inj *preset.MCPInjection, identity, bin, socket string) ([]string, error) {
flag := inj.Flag
if flag == "" {
flag = "-c"
}
prefix := inj.KeyPrefix
if prefix == "" {
return nil, fmt.Errorf("preset %s: mcp_injection.cli_override requires key_prefix", p.Name)
}
args := []string{"mcp-stdio", "--socket", socket, "--identity", identity}
// We hard-code TOML scalar encoding because every consumer in the
// wild (codex today; future cli_override targets are expected to
// be the same) parses overrides as TOML expressions. Quoting the
// command preserves spaces in paths; quoting each args element
// keeps the array shape intact.
cmdVal := tomlString(bin)
var argsVal strings.Builder
argsVal.WriteString("[")
for i, a := range args {
if i > 0 {
argsVal.WriteString(", ")
}
argsVal.WriteString(tomlString(a))
}
argsVal.WriteString("]")
return []string{
flag, prefix + ".command=" + cmdVal,
flag, prefix + ".args=" + argsVal.String(),
}, nil
}
// tomlString renders a Go string as a TOML basic string literal. TOML
// uses the same escape conventions as JSON for backslash and quote,
// which keeps this implementation small.
func tomlString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// inferHomeFromPath maps the well-known agent config paths to the env
// var + relative path patterm should use when merging. Lets older
// preset files (without home_var/home_path/format) keep working.
func inferHomeFromPath(path string) (homeVar, homePath, format string) {
switch {
case strings.HasSuffix(path, "/.codex/config.toml"):
return "CODEX_HOME", "config.toml", "toml"
case strings.HasSuffix(path, "/opencode/opencode.json"):
return "XDG_CONFIG_HOME", "opencode/opencode.json", "json"
}
return "", "", ""
}
// mirrorAgentHome populates mirroredDir with symlinks pointing at each
// entry of srcDir, except for skipBase (which the caller is replacing
// with a freshly-written file). This lets agents that root every piece
// of their per-user state at one dir — codex via CODEX_HOME, opencode
// via XDG_CONFIG_HOME/opencode — keep reading their real auth.json,
// sessions, history, etc. even when patterm overrides the home root.
func mirrorAgentHome(srcDir, mirroredDir, skipBase string) error {
if err := os.MkdirAll(mirroredDir, 0o700); err != nil {
return err
}
entries, err := os.ReadDir(srcDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
for _, e := range entries {
if e.Name() == skipBase {
continue
}
src := filepath.Join(srcDir, e.Name())
dst := filepath.Join(mirroredDir, e.Name())
// Replace any stale symlink/file at dst — the runtime dir is
// per-identity so this should be a no-op on first spawn, but
// being defensive keeps re-spawn semantics sane if the dir is
// reused.
_ = os.Remove(dst)
if err := os.Symlink(src, dst); err != nil {
return fmt.Errorf("symlink %s -> %s: %w", src, dst, err)
}
}
return nil
}
func mcpRuntimeDir(identity string) (string, error) {
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
dir := filepath.Join(runtime, "patterm", "agents", identity)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return dir, nil
}
dir := filepath.Join(os.TempDir(), "patterm-agents-"+identity)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return dir, nil
}
func expandUser(p string) string {
if strings.HasPrefix(p, "~/") {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, p[2:])
}
}
return p
}
// mergeJSONMCP parses src as JSON, slots patterm's MCP entry under the
// merge key, and reserializes. If src is empty/whitespace, we start
// from an empty object. opencode uses a `command` array shape with
// `type: "local"`; codex JSON variants (uncommon) reuse the codex
// command/args shape. We emit the opencode shape because it's the
// only JSON consumer in the default preset set.
func mergeJSONMCP(src []byte, mergeKey, bin string, args []string) ([]byte, error) {
var root map[string]any
trimmed := strings.TrimSpace(string(src))
if trimmed == "" {
root = map[string]any{}
} else {
if err := json.Unmarshal([]byte(trimmed), &root); err != nil {
return nil, fmt.Errorf("parse json config: %w", err)
}
}
mcp, _ := root[mergeKey].(map[string]any)
if mcp == nil {
mcp = map[string]any{}
}
entry := map[string]any{
"type": "local",
"command": append([]string{bin}, args...),
"enabled": true,
}
mcp[patternMcpEntryName] = entry
root[mergeKey] = mcp
out, err := json.MarshalIndent(root, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
// mergeTOMLMCP merges a `[<mergeKey>.patterm]` block into a TOML
// document. We deliberately avoid pulling in a full TOML parser:
// codex's config.toml is human-edited but the patterm entry is
// well-bounded, so a string-level "strip the old patterm section,
// append a fresh one" suffices for the merge use case.
func mergeTOMLMCP(src []byte, mergeKey, bin string, args []string) ([]byte, error) {
stripped := stripTOMLSection(string(src), mergeKey+"."+patternMcpEntryName)
if stripped != "" && !strings.HasSuffix(stripped, "\n") {
stripped += "\n"
}
if stripped != "" {
stripped += "\n"
}
var b strings.Builder
b.WriteString(stripped)
b.WriteString("# managed by patterm — re-written on each spawn\n")
fmt.Fprintf(&b, "[%s.%s]\n", mergeKey, patternMcpEntryName)
fmt.Fprintf(&b, "command = %q\n", bin)
b.WriteString("args = [")
for i, a := range args {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%q", a)
}
b.WriteString("]\n")
return []byte(b.String()), nil
}
// stripTOMLSection returns src with the `[header]` table (and the
// lines until the next top-level `[...]` header or EOF) removed.
// Lines that begin with `header.` as a subsection of the target are
// also dropped so we don't leave stale per-key dotted assignments.
func stripTOMLSection(src, header string) string {
if src == "" {
return ""
}
wantTable := "[" + header + "]"
wantSubPrefix := "[" + header + "."
lines := strings.Split(src, "\n")
out := make([]string, 0, len(lines))
inTarget := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
if trimmed == wantTable || strings.HasPrefix(trimmed, wantSubPrefix) {
inTarget = true
continue
}
inTarget = false
}
if inTarget {
continue
}
out = append(out, line)
}
joined := strings.Join(out, "\n")
return strings.TrimRight(joined, "\n")
}