Files
patterm/internal/preset/preset.go
Harry Bayliss 2b9e1ed77c Add idle-state classifier and Solo-parity timer tools
Classifies every running child as idle/working/thinking/permission/error
using one of three pluggable strategies (output_activity,
osc_title_stability, osc_title_status) plus optional regex promoters
applied to the tail of recent output. State and last-match reason are
exposed via MCP on ProcessInfo and get_process_status. Per-preset
configuration lives on a new preset.IdleDetection block with bundled
defaults for the first-party claude/codex/opencode presets.

OSC title plumbing is exposed as Emulator.Title(), polled from the
session pump after each emulator write so title-change activity feeds
into the classifier without an extra cgo callback.

The MCP timer surface expands to match Solo: timer_set,
timer_fire_when_idle_any/all, timer_cancel, timer_pause, timer_resume,
timer_list. timer_wait is now a thin wrapper that shares the same
manager so it shows up in timer_list while pending. Timer bodies are
delivered to the owner process through the existing
InjectAsOrchestrator path. Top-level (non-agent) callers can attach
timers to a specific process via owner_process_id; omitting it grants
universal cancel/pause/resume/list privileges.

The sidebar gains a state glyph per process row and appends a
nearest-timer indicator when one is pending or paused.

Tests: idle_test.go covers the classify() pure function across the
three strategies and regex promotion; timers_test.go covers the
manager. Harness scenarios cover output_activity, osc_title_stability,
osc_title_status, and regex promotion, plus timer_set delivery,
cancel, pause/resume, idle_any-on-transition, idle_all-pending, and
idle_all-already-satisfied. A new wait_until_mcp harness step type
polls an MCP method until an assertion holds.
2026-05-15 09:49:59 +01:00

336 lines
9.4 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*>_"
]
}
`,
},
{
"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
}