401 lines
11 KiB
Go
401 lines
11 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 (
|
||
"bytes"
|
||
"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"`
|
||
Disabled bool `json:"disabled,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"`
|
||
IdleDetection *IdleDetection `json:"idle_detection,omitempty"`
|
||
}
|
||
|
||
// IdleDetection configures steady-state idle classification for an
|
||
// agent preset. Independent of ReadySignal (which is startup-only).
|
||
// All fields are optional; when the whole block is nil the runtime
|
||
// falls back to output_activity with a 2s threshold.
|
||
//
|
||
// Strategy selects the primary signal:
|
||
// - "output_activity": ms since last PTY output (Claude, OpenCode).
|
||
// - "osc_title_stability": ms since last OSC 0/2 title change
|
||
// (Codex, Amp — title changes mean activity).
|
||
// - "osc_title_status": substring-match the current title against
|
||
// TitleStatusMap (Gemini — title carries a status word).
|
||
//
|
||
// Promoter patterns are applied on top of the strategy. They run
|
||
// against the recent ring-buffer tail; the first match wins in
|
||
// error > permission > thinking precedence and promotes the state
|
||
// over whatever the strategy returned.
|
||
type IdleDetection struct {
|
||
Strategy string `json:"strategy,omitempty"`
|
||
IdleThresholdMS int `json:"idle_threshold_ms,omitempty"`
|
||
|
||
// TitleStatusMap maps a (case-insensitive) substring of the OSC
|
||
// title to a state. Only meaningful for "osc_title_status".
|
||
// Allowed values: "idle", "working", "thinking", "permission", "error".
|
||
TitleStatusMap map[string]string `json:"title_status_map,omitempty"`
|
||
|
||
// Output regex promoters. Compiled at load time; bad patterns are
|
||
// surfaced as warnings and skipped.
|
||
PermissionPatterns []string `json:"permission_patterns,omitempty"`
|
||
ThinkingPatterns []string `json:"thinking_patterns,omitempty"`
|
||
ErrorPatterns []string `json:"error_patterns,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 returns the built-in presets plus user overlays from
|
||
// $XDG_CONFIG_HOME/patterm/presets/{agents,processes}/*.json. Startup
|
||
// does not write default files; user files only override or extend the
|
||
// in-memory defaults. A user overlay with {"disabled": true} hides a
|
||
// built-in preset of the same name.
|
||
func Load() (Set, error) {
|
||
base, err := ConfigDir()
|
||
if err != nil {
|
||
return Set{}, err
|
||
}
|
||
|
||
agents, err := loadWithDefaults(filepath.Join(base, "presets", "agents"), KindAgent, defaultAgentPresets())
|
||
if err != nil {
|
||
return Set{}, err
|
||
}
|
||
procs, err := loadWithDefaults(filepath.Join(base, "presets", "processes"), KindCommand, nil)
|
||
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 loadWithDefaults(dir string, kind Kind, defaults []*Preset) ([]*Preset, error) {
|
||
byName := make(map[string]*Preset, len(defaults))
|
||
for _, p := range defaults {
|
||
cp := clonePreset(p)
|
||
cp.Kind = kind
|
||
byName[cp.Name] = cp
|
||
}
|
||
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return sortedPresets(byName), nil
|
||
}
|
||
return nil, fmt.Errorf("preset: read %s: %w", dir, err)
|
||
}
|
||
for _, e := range entries {
|
||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||
continue
|
||
}
|
||
path := filepath.Join(dir, e.Name())
|
||
p, err := loadFileOverlay(path, kind, byName)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err)
|
||
continue
|
||
}
|
||
if p.Disabled {
|
||
delete(byName, p.Name)
|
||
continue
|
||
}
|
||
byName[p.Name] = p
|
||
}
|
||
return sortedPresets(byName), nil
|
||
}
|
||
|
||
func sortedPresets(byName map[string]*Preset) []*Preset {
|
||
out := make([]*Preset, 0, len(byName))
|
||
for _, p := range byName {
|
||
out = append(out, p)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||
return out
|
||
}
|
||
|
||
func loadFileOverlay(path string, kind Kind, defaults map[string]*Preset) (*Preset, error) {
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var header struct {
|
||
Name string `json:"name"`
|
||
Disabled bool `json:"disabled,omitempty"`
|
||
}
|
||
if err := json.Unmarshal(b, &header); err != nil {
|
||
return nil, err
|
||
}
|
||
if header.Name == "" {
|
||
return nil, errors.New("missing 'name'")
|
||
}
|
||
if def := defaults[header.Name]; def != nil {
|
||
p, err := mergePreset(def, b)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
p.Path = path
|
||
p.Kind = kind
|
||
return p, validatePreset(p)
|
||
}
|
||
var p Preset
|
||
if err := json.Unmarshal(b, &p); err != nil {
|
||
return nil, err
|
||
}
|
||
p.Path = path
|
||
p.Kind = kind
|
||
return &p, validatePreset(&p)
|
||
}
|
||
|
||
func validatePreset(p *Preset) error {
|
||
if p.Name == "" {
|
||
return errors.New("missing 'name'")
|
||
}
|
||
if p.Disabled {
|
||
return nil
|
||
}
|
||
if len(p.Argv) == 0 && !p.Shell {
|
||
return errors.New("missing 'argv'")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func mergePreset(def *Preset, overlay []byte) (*Preset, error) {
|
||
base, err := presetMap(def)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var over map[string]any
|
||
dec := json.NewDecoder(bytes.NewReader(overlay))
|
||
dec.UseNumber()
|
||
if err := dec.Decode(&over); err != nil {
|
||
return nil, err
|
||
}
|
||
deepMerge(base, over)
|
||
b, err := json.Marshal(base)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var p Preset
|
||
if err := json.Unmarshal(b, &p); err != nil {
|
||
return nil, err
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
func presetMap(p *Preset) (map[string]any, error) {
|
||
b, err := json.Marshal(p)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var m map[string]any
|
||
dec := json.NewDecoder(bytes.NewReader(b))
|
||
dec.UseNumber()
|
||
if err := dec.Decode(&m); err != nil {
|
||
return nil, err
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func deepMerge(dst, src map[string]any) {
|
||
for k, v := range src {
|
||
if sm, ok := v.(map[string]any); ok {
|
||
if dm, ok := dst[k].(map[string]any); ok {
|
||
deepMerge(dm, sm)
|
||
continue
|
||
}
|
||
}
|
||
dst[k] = v
|
||
}
|
||
}
|
||
|
||
func clonePreset(p *Preset) *Preset {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
b, _ := json.Marshal(p)
|
||
var out Preset
|
||
_ = json.Unmarshal(b, &out)
|
||
return &out
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
func defaultAgentPresets() []*Preset {
|
||
bodies := []string{
|
||
`{
|
||
"name": "claude",
|
||
"argv": ["claude"],
|
||
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
|
||
"ready_signal": { "idle_ms": 1000 },
|
||
"idle_detection": {
|
||
"strategy": "output_activity",
|
||
"idle_threshold_ms": 2000,
|
||
"permission_patterns": [
|
||
"Do you want to proceed\\?",
|
||
"❯ 1\\. Yes",
|
||
"1\\. Yes, and don't ask"
|
||
]
|
||
},
|
||
"chrome_trim_hints": [
|
||
"^Welcome to Claude Code",
|
||
"^/help for help",
|
||
"^cwd:",
|
||
"^\\s*│\\s*>",
|
||
"^\\s*╭─+╮$",
|
||
"^\\s*╰─+╯$",
|
||
"^\\? for shortcuts"
|
||
]
|
||
}
|
||
`,
|
||
`{
|
||
"name": "codex",
|
||
"argv": ["codex"],
|
||
"mcp_injection": {
|
||
"kind": "cli_override",
|
||
"flag": "-c",
|
||
"key_prefix": "mcp_servers.patterm",
|
||
"format": "toml"
|
||
},
|
||
"ready_signal": { "idle_ms": 1000 },
|
||
"idle_detection": {
|
||
"strategy": "osc_title_stability",
|
||
"idle_threshold_ms": 2000
|
||
},
|
||
"chrome_trim_hints": [
|
||
"^OpenAI Codex",
|
||
"^\\s*model:",
|
||
"^\\s*workdir:",
|
||
"^>_$",
|
||
"^\\s*▌"
|
||
]
|
||
}
|
||
`,
|
||
`{
|
||
"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 },
|
||
"idle_detection": {
|
||
"strategy": "output_activity",
|
||
"idle_threshold_ms": 2000
|
||
},
|
||
"chrome_trim_hints": [
|
||
"^\\s*█",
|
||
"^\\s*opencode v",
|
||
"^\\s*~/",
|
||
"^\\s*>_"
|
||
]
|
||
}
|
||
`,
|
||
}
|
||
out := make([]*Preset, 0, len(bodies))
|
||
for _, body := range bodies {
|
||
var p Preset
|
||
if err := json.Unmarshal([]byte(body), &p); err != nil {
|
||
panic(err)
|
||
}
|
||
p.Kind = KindAgent
|
||
out = append(out, &p)
|
||
}
|
||
return out
|
||
}
|