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.
336 lines
9.4 KiB
Go
336 lines
9.4 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"`
|
||
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
|
||
}
|