- 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.
270 lines
7.1 KiB
Go
270 lines
7.1 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*>_"
|
|
]
|
|
}
|
|
`,
|
|
},
|
|
}
|
|
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
|
|
}
|