// 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 ` .=` // 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 }