Files
patterm/internal/preset/preset.go
Harry Bayliss 01fc108086 Rename Kill to Close, add New Terminal palette entry, clean up exited terminals
- Palette's per-child "Kill <name>" action is now labelled "Close <name>"
  (action kind unchanged; still SIGTERM). Matches the existing "Close
  agent: …" context entry and reads less violent for a graceful term.
- New "New Terminal" palette entry spawns a bare interactive $SHELL pane
  via LaunchTerminal (kind=terminal). Replaces the default "shell"
  process preset that was seeded on first run.
- Exited KindTerminal entries are now dropped from the session in
  reapChild — terminals have no restart path, so leaving them behind as
  greyed rows in the Processes sidebar was just clutter. processList
  also filters defensively.
2026-05-15 11:30:46 +01:00

320 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"`
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 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 },
"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"
]
}
`,
},
{
"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 },
"idle_detection": {
"strategy": "osc_title_stability",
"idle_threshold_ms": 2000
},
"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 },
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 2000
},
"chrome_trim_hints": [
"^\\s*█",
"^\\s*opencode v",
"^\\s*~/",
"^\\s*>_"
]
}
`,
},
}
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
}
if err := os.WriteFile(full, []byte(d.body), 0o600); err != nil {
return err
}
}
return nil
}