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

286 lines
7.4 KiB
Go

// Package preset loads user-editable JSON files that describe how to
// launch agents and processes. SPEC §10: every spawnable thing is a
// preset; patterm has no hard-coded agent or process types.
package preset
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Kind is "agent" or "command". Process presets are SPEC §7's
// `command` kind (session-persistent). Terminal entries don't have
// presets — they're created freeform with `kind: terminal` via
// spawn_process.
type Kind string
const (
KindAgent Kind = "agent"
KindCommand Kind = "command"
)
// Preset is one loaded preset file. Agent-only fields stay zero on
// process presets and vice versa.
type Preset struct {
// Source path (informational; not serialized).
Path string `json:"-"`
Kind Kind `json:"-"`
Name string `json:"name"`
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
// Process-only.
Shell bool `json:"shell,omitempty"`
// Agent-only. SPEC §10.
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
}
// MCPInjection covers the strategies SPEC §10 enumerates plus
// `cli_override` for agents (like codex) that accept inline config
// overrides via repeated CLI flags, and `config_env` for agents (like
// opencode) that read their config from an env var. The fields used
// depend on Kind.
type MCPInjection struct {
Kind string `json:"kind"` // "flag" | "config_file" | "env_var" | "cli_override" | "config_env"
Flag string `json:"flag,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
Var string `json:"var,omitempty"`
// config_file fields. patterm reads the file at Path, merges in a
// `patterm` entry under MergeKey, writes the result inside a temp
// directory laid out so HomeVar + HomePath points at the merged
// file, and exports HomeVar to the child. Format is inferred from
// Path's extension (toml or json) when blank.
Path string `json:"path,omitempty"`
MergeKey string `json:"merge_key,omitempty"`
Format string `json:"format,omitempty"`
HomeVar string `json:"home_var,omitempty"`
HomePath string `json:"home_path,omitempty"`
// cli_override fields. patterm emits one `<Flag> <KeyPrefix>.<k>=<v>`
// pair per MCP setting (command, args) so the agent merges them
// into its in-memory config without touching any file on disk. Used
// for codex's `-c key=value`.
KeyPrefix string `json:"key_prefix,omitempty"`
}
// ReadySignal lets a preset override the default 1s-idle heuristic.
type ReadySignal struct {
IdleMS int `json:"idle_ms,omitempty"`
Pattern string `json:"pattern,omitempty"`
}
// Set is what the palette consumes.
type Set struct {
Agents []*Preset
Processes []*Preset
}
// Load scans the standard locations under $XDG_CONFIG_HOME/patterm/
// presets/{agents,processes}/*.json. Unknown files are skipped with a
// warning to stderr; the spec is forgiving here.
func Load() (Set, error) {
base, err := ConfigDir()
if err != nil {
return Set{}, err
}
if err := os.MkdirAll(base, 0o700); err != nil {
return Set{}, fmt.Errorf("preset: mkdir %s: %w", base, err)
}
// Make sure the default-preset files exist on first run. Idempotent.
if err := ensureDefaults(base); err != nil {
return Set{}, err
}
agents, err := loadDir(filepath.Join(base, "presets", "agents"), KindAgent)
if err != nil {
return Set{}, err
}
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindCommand)
if err != nil {
return Set{}, err
}
return Set{Agents: agents, Processes: procs}, nil
}
// ConfigDir resolves $XDG_CONFIG_HOME/patterm (with the conventional
// fallback to ~/.config/patterm).
func ConfigDir() (string, error) {
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
return filepath.Join(h, "patterm"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "patterm"), nil
}
func loadDir(dir string, kind Kind) ([]*Preset, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("preset: mkdir %s: %w", dir, err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("preset: read %s: %w", dir, err)
}
var out []*Preset
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(dir, e.Name())
p, err := loadFile(path, kind)
if err != nil {
fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err)
continue
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func loadFile(path string, kind Kind) (*Preset, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var p Preset
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
if p.Name == "" {
return nil, errors.New("missing 'name'")
}
if len(p.Argv) == 0 && !p.Shell {
return nil, errors.New("missing 'argv'")
}
p.Path = path
p.Kind = kind
return &p, nil
}
// ResolvedArgv returns the argv to actually exec, handling the
// process-preset "shell: true" case (SPEC §10).
func (p *Preset) ResolvedArgv() []string {
if p.Shell && len(p.Argv) > 0 {
return []string{"sh", "-lc", strings.Join(p.Argv, " ")}
}
return p.Argv
}
// ensureDefaults writes default agent presets (claude/codex/opencode)
// and a sample process preset on first run. Never overwrites existing
// user files.
func ensureDefaults(base string) error {
defaults := []struct {
rel string
body string
}{
{
"presets/agents/claude.json",
`{
"name": "claude",
"argv": ["claude"],
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
"ready_signal": { "idle_ms": 1000 },
"chrome_trim_hints": [
"^Welcome to Claude Code",
"^/help for help",
"^cwd:",
"^\\s*│\\s*>",
"^\\s*╭─+╮$",
"^\\s*╰─+╯$",
"^\\? for shortcuts"
]
}
`,
},
{
"presets/agents/codex.json",
`{
"name": "codex",
"argv": ["codex"],
"mcp_injection": {
"kind": "cli_override",
"flag": "-c",
"key_prefix": "mcp_servers.patterm",
"format": "toml"
},
"ready_signal": { "idle_ms": 1000 },
"chrome_trim_hints": [
"^OpenAI Codex",
"^\\s*model:",
"^\\s*workdir:",
"^>_$",
"^\\s*▌"
]
}
`,
},
{
"presets/agents/opencode.json",
`{
"name": "opencode",
"argv": ["opencode"],
"mcp_injection": {
"kind": "config_env",
"path": "~/.config/opencode/opencode.json",
"merge_key": "mcp",
"format": "json",
"var": "OPENCODE_CONFIG_CONTENT"
},
"ready_signal": { "idle_ms": 1000 },
"chrome_trim_hints": [
"^\\s*█",
"^\\s*opencode v",
"^\\s*~/",
"^\\s*>_"
]
}
`,
},
{
"presets/processes/shell.json",
`{
"name": "shell",
"argv": ["__SHELL__"]
}
`,
},
}
for _, d := range defaults {
full := filepath.Join(base, d.rel)
if _, err := os.Stat(full); err == nil {
continue
}
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
return err
}
body := d.body
if strings.Contains(body, "__SHELL__") {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
body = strings.ReplaceAll(body, "__SHELL__", shell)
}
if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
return err
}
}
return nil
}