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:
155
internal/app/debug.go
Normal file
155
internal/app/debug.go
Normal file
@@ -0,0 +1,155 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user