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.
286 lines
7.4 KiB
Go
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
|
|
}
|