Files
patterm/internal/app/debug.go
Harry Bayliss c120342709 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.
2026-05-15 12:41:47 +01:00

156 lines
3.6 KiB
Go

package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// debugCapture implements ChildEventListener and writes structured
// debug artefacts under a single directory:
//
// - patterm.log — the existing logf() stream
// - events.jsonl — one JSON object per lifecycle event
// - <id>.raw — raw PTY bytes for each child, by id+name
//
// The capture is installed only when --debug=<dir> is set, so default
// runs pay nothing.
type debugCapture struct {
dir string
logPath string
mu sync.Mutex
events *os.File
rawByID map[string]*os.File
}
func openDebugCapture(dir string) (*debugCapture, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
logPath := filepath.Join(dir, "patterm.log")
// Truncate-style fresh log per run is friendlier for grep'ing one
// session. The existing logf opens O_APPEND though, so concurrent
// runs against the same dir would interleave — that's on the user.
if f, err := os.Create(logPath); err != nil {
return nil, err
} else {
_ = f.Close()
}
ev, err := os.Create(filepath.Join(dir, "events.jsonl"))
if err != nil {
return nil, err
}
dc := &debugCapture{
dir: dir,
logPath: logPath,
events: ev,
rawByID: make(map[string]*os.File),
}
dc.writeEvent("session_start", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"pid": os.Getpid(),
})
return dc, nil
}
func (d *debugCapture) LogPath() string { return d.logPath }
func (d *debugCapture) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
d.writeEventLocked("session_end", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
})
for _, f := range d.rawByID {
_ = f.Close()
}
d.rawByID = nil
if d.events != nil {
_ = d.events.Close()
d.events = nil
}
return nil
}
func (d *debugCapture) OnChildSpawned(c *Child) {
d.writeEvent("child_spawned", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": c.ID,
"name": c.Name,
"kind": string(c.Kind),
"parent_id": c.ParentID,
"preset": c.PresetRef,
"argv": c.Argv,
})
}
func (d *debugCapture) OnChildExited(c *Child) {
d.writeEvent("child_exited", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": c.ID,
"name": c.Name,
"exit_code": c.ExitCode(),
})
d.mu.Lock()
defer d.mu.Unlock()
if f, ok := d.rawByID[c.ID]; ok {
_ = f.Close()
delete(d.rawByID, c.ID)
}
}
func (d *debugCapture) OnChildStateChanged(id string, state IdleState) {
d.writeEvent("child_state", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": id,
"state": string(state),
})
}
func (d *debugCapture) OnPTYOut(childID string, chunk []byte) {
if len(chunk) == 0 {
return
}
d.mu.Lock()
defer d.mu.Unlock()
f, ok := d.rawByID[childID]
if !ok {
path := filepath.Join(d.dir, childID+".raw")
nf, err := os.Create(path)
if err != nil {
return
}
f = nf
d.rawByID[childID] = nf
}
// Listener contract: don't retain chunk past return. Writing now
// is fine; the slice's backing buffer is reused for the next read
// only after this listener chain completes.
_, _ = f.Write(chunk)
}
func (d *debugCapture) writeEvent(kind string, fields map[string]any) {
d.mu.Lock()
defer d.mu.Unlock()
d.writeEventLocked(kind, fields)
}
func (d *debugCapture) writeEventLocked(kind string, fields map[string]any) {
if d.events == nil {
return
}
if fields == nil {
fields = map[string]any{}
}
fields["event"] = kind
enc, err := json.Marshal(fields)
if err != nil {
return
}
_, _ = fmt.Fprintln(d.events, string(enc))
}