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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user