// patterm is a terminal-based agent orchestration shell. SPEC §2: one // foreground process owns the TUI, every PTY, and the in-process MCP // server. Closing the terminal window ends the session. // // patterm run in $PWD // patterm --project run in // patterm --version print version and exit // patterm mcp-stdio --socket S --identity I // internal: stdio MCP proxy spawned for // children, forwards JSON-RPC over S // patterm debug-harness --scenario S // internal: run a black-box harness scenario package main import ( "context" "fmt" "os" "path/filepath" "runtime" "runtime/debug" "runtime/pprof" "time" flag "github.com/spf13/pflag" "github.com/hjbdev/patterm/internal/app" "github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/projectkey" ) // version is overridden at build time via `-ldflags "-X main.version=..."`. // Defaults to "dev" so source builds are still meaningful. var version = "dev" func main() { // The mcp-stdio subcommand is a separate top-level mode: when an // agent CLI launches `patterm mcp-stdio --socket ...`, the same // binary forwards JSON-RPC to the running process over the per-PID // socket. SPEC §10. if len(os.Args) >= 2 && os.Args[1] == "mcp-stdio" { os.Args = append(os.Args[:1], os.Args[2:]...) runMCPProxy() return } if len(os.Args) >= 2 && os.Args[1] == "debug-harness" { os.Args = append(os.Args[:1], os.Args[2:]...) runDebugHarness() return } var ( projectDir = flag.String("project", "", "project directory (default $PWD)") showVersion = flag.Bool("version", false, "print version and exit") debugDir = flag.String("debug", "", "write debug logs + per-child raw PTY output to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/debug when DIR is omitted)") profileDir = flag.String("profile", "", "write pprof files (cpu/heap/goroutine) and live perf counters (metrics.jsonl per-second, metrics.json + summary.txt on exit) to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/profile when DIR is omitted)") ) // Allow bare `--debug` / `--profile` with no value — pflag treats // them as boolean-shaped strings, picking a sensible default dir. flag.Lookup("debug").NoOptDefVal = "auto" flag.Lookup("profile").NoOptDefVal = "auto" flag.Parse() if *showVersion { fmt.Println(versionString()) return } cwd, err := os.Getwd() if err != nil { die("getwd: %v", err) } if *projectDir != "" { cwd = *projectDir } key, err := projectkey.Key(cwd) if err != nil { die("project key: %v", err) } if err := os.Chdir(cwd); err != nil { die("chdir %s: %v", cwd, err) } resolvedDebug, err := resolveDiagDir(*debugDir, "debug") if err != nil { die("debug: %v", err) } resolvedProfile, err := resolveDiagDir(*profileDir, "profile") if err != nil { die("profile: %v", err) } stopProfile := startProfile(resolvedProfile) defer stopProfile() ctx := context.Background() if err := app.Run(ctx, app.Options{ ProjectDir: cwd, ProjectKey: key, DebugDir: resolvedDebug, ProfileDir: resolvedProfile, }); err != nil { die("%v", err) } } // resolveDiagDir turns the raw flag value into an absolute directory // path. Empty string disables the feature. The sentinel "auto" (set by // NoOptDefVal on bare flags) picks $XDG_STATE_HOME/patterm//. // Any other value is treated as a literal path. func resolveDiagDir(raw, kind string) (string, error) { if raw == "" { return "", nil } if raw == "auto" { base := os.Getenv("XDG_STATE_HOME") if base == "" { home, err := os.UserHomeDir() if err != nil { return "", err } base = filepath.Join(home, ".local", "state") } ts := time.Now().Format("20060102-150405") return filepath.Join(base, "patterm", kind, ts), nil } return raw, nil } // startProfile begins a CPU profile under dir and returns a stop func // that writes heap + goroutine snapshots before flushing the CPU file. // Returns a no-op stop func when dir is empty. All diagnostics are // written to /profile.log — never to stdout/stderr — so the TUI // stays uncluttered. func startProfile(dir string) func() { if dir == "" { return func() {} } if err := os.MkdirAll(dir, 0o700); err != nil { return func() {} } logPath := filepath.Join(dir, "profile.log") plog := func(format string, args ...any) { f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return } defer f.Close() fmt.Fprintf(f, format+"\n", args...) } cpuPath := filepath.Join(dir, "cpu.pprof") f, err := os.Create(cpuPath) if err != nil { plog("cpu open: %v", err) return func() {} } if err := pprof.StartCPUProfile(f); err != nil { plog("cpu start: %v", err) _ = f.Close() return func() {} } plog("profiling started at %s", time.Now().Format(time.RFC3339Nano)) return func() { pprof.StopCPUProfile() _ = f.Close() // Heap and goroutine snapshots at exit. Heap captures // steady-state allocation; goroutine catches stragglers // that didn't get cleaned up. runtime.GC() if hf, err := os.Create(filepath.Join(dir, "heap.pprof")); err == nil { _ = pprof.Lookup("heap").WriteTo(hf, 0) _ = hf.Close() } if gf, err := os.Create(filepath.Join(dir, "goroutine.pprof")); err == nil { _ = pprof.Lookup("goroutine").WriteTo(gf, 0) _ = gf.Close() } plog("profiling stopped at %s", time.Now().Format(time.RFC3339Nano)) } } func runMCPProxy() { var ( socket = flag.String("socket", "", "path to the running patterm process's MCP socket") identity = flag.String("identity", "", "per-child identity token") ) flag.Parse() if *socket == "" || *identity == "" { die("mcp-stdio: --socket and --identity are required") } if err := mcp.RunStdioProxy(*socket, *identity); err != nil { die("mcp-stdio: %v", err) } } func versionString() string { commit, date := "unknown", "unknown" if info, ok := debug.ReadBuildInfo(); ok { dirty := false for _, s := range info.Settings { switch s.Key { case "vcs.revision": if len(s.Value) >= 7 { commit = s.Value[:7] } else if s.Value != "" { commit = s.Value } case "vcs.time": if t, err := time.Parse(time.RFC3339, s.Value); err == nil { date = t.Format("2006-01-02") } case "vcs.modified": dirty = s.Value == "true" } } if dirty && commit != "unknown" { commit += "-dirty" } } return fmt.Sprintf("patterm %s (commit %s, built %s)", version, commit, date) } func die(format string, args ...any) { fmt.Fprintf(os.Stderr, "patterm: "+format+"\n", args...) os.Exit(1) }