Clear TODO backlog: --debug/--profile, codex selection, MCP orientation, perf

- Add --debug[=DIR] / --profile[=DIR] flags that write run artefacts
  (patterm.log, events.jsonl, per-child raw PTY captures, CPU + heap
  + goroutine pprof) to a dir without polluting stdout/stderr.
- Strengthen vendor-TUI orientation in three places (MCP
  initialize.instructions, the spawn_agent tool description, and
  help('spawning')) to head off codex's habits of poking the Unix
  socket via perl and shelling out to launch peers — both bypass
  caller identity and produce orphaned top-level tabs.
- Fix click-and-drag text selection from alt-screen TUIs. Host SGR
  mouse reporting now follows the focused child's screen side
  instead of being permanently armed; alt-screen TUIs that need
  mouse re-enable it themselves and the toggle is forwarded.
- Move drawSidebar() off the per-PTY-chunk hot path. Long claude
  session resume was paying a full sidebar rebuild for every
  scrolled chunk; the chrome ticker now drains a dirty flag at 60 Hz.
- Gate the per-chunk Title() CGO poll on a containsOSC scan so
  codex/ratatui's many SGR-only chunks no longer pay a CGO call each.
This commit is contained in:
2026-05-15 12:41:47 +01:00
parent 01fc108086
commit c120342709
11 changed files with 536 additions and 36 deletions

View File

@@ -16,7 +16,10 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"runtime/pprof"
"time"
flag "github.com/spf13/pflag"
@@ -49,7 +52,13 @@ func main() {
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 CPU+heap+goroutine pprof files 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 {
@@ -73,15 +82,103 @@ func main() {
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,
}); 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")