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.
378 lines
12 KiB
Go
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")
|
|
}
|