Live metrics (--profile): - New metricsTracker instruments OnPTYOut, viewport renderer, stdout writes, libghostty-vt Write/Title CGO calls, sidebar / tabbar / status draws (with cache-hit accounting), snapshot replays, and the chrome ticker (so we can see ticker fires that did nothing). - Writes metrics.jsonl (one snapshot per second) and metrics.json + summary.txt on exit, alongside the existing pprof files. - All record* methods are nil-safe so disabled paths pay only a cheap nil check; counters are atomic so the per-PTY-chunk hot path stays lock-free. Benchmark suite (go test -bench=.): - Three workload fixtures — plain ASCII, SGR-styled lines, and a ratatui-style cursor-shuffling burst — plus a containsOSC microbenchmark. Reports ns/op, MB/s, allocs/op, B/op. - Initial baseline numbers added to TODO under the perf-audit section, alongside two new findings (renderer allocs ~1 per 4 bytes on styled chunks; styled throughput tops out near 90 MB/s) those benchmarks surfaced.
228 lines
6.6 KiB
Go
228 lines
6.6 KiB
Go
// 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 <dir> run in <dir>
|
|
// 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/<kind>/<ts>.
|
|
// 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 <dir>/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)
|
|
}
|