Initial patterm project

This commit is contained in:
2026-05-14 13:37:20 +01:00
commit 69ef09aac4
40 changed files with 6521 additions and 0 deletions

79
cmd/patterm/main.go Normal file
View File

@@ -0,0 +1,79 @@
// 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 mcp-stdio --socket S --identity I
// internal: stdio MCP proxy spawned for
// children, forwards JSON-RPC over S
package main
import (
"context"
"flag"
"fmt"
"os"
"github.com/harrybrwn/patterm/internal/app"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/projectkey"
)
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
}
var projectDir = flag.String("project", "", "project directory (default $PWD)")
flag.Parse()
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)
}
ctx := context.Background()
if err := app.Run(ctx, app.Options{
ProjectDir: cwd,
ProjectKey: key,
}); err != nil {
die("%v", err)
}
}
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 die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "patterm: "+format+"\n", args...)
os.Exit(1)
}

390
cmd/spike/main.go Normal file
View File

@@ -0,0 +1,390 @@
// cmd/spike is the milestone-1 throwaway: spawn a child in a PTY, pump bytes
// through a libghostty-vt-backed emulator, and dump the rendered grid as
// plain text on idle or hotkey.
//
// Stdin from the host terminal is forwarded raw to the child PTY, so vim,
// htop, claude, codex and friends behave as if you ran them directly. We are
// explicitly NOT encoding keys ourselves yet — that's a daemon-era concern.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/harrybrwn/patterm/internal/pty"
"github.com/harrybrwn/patterm/internal/vt"
cpty "github.com/creack/pty"
"golang.org/x/term"
)
const (
defaultCols = 120
defaultRows = 40
defaultIdleMS = 1000
readBufferBytes = 64 * 1024
)
// Known hotkey aliases mapped to their raw control bytes.
var hotkeyAliases = map[string]byte{
"ctrl-]": 0x1d, // GS — default, but some layouts/terminals swallow it
"ctrl-\\": 0x1c, // FS — sends SIGQUIT in cooked mode but raw passes through
"ctrl-^": 0x1e, // RS
"ctrl-_": 0x1f, // US
"ctrl-t": 0x14,
"ctrl-o": 0x0f,
"ctrl-space": 0x00, // NUL
}
type spikeFlags struct {
cols, rows int
idleMS int
followHost bool
noPassthrough bool
bytesPath string
gridPath string
gridToStderr bool
hotkey string
debugStdin bool
}
func main() {
var f spikeFlags
flag.IntVar(&f.cols, "cols", defaultCols, "PTY columns (overridden by host size if -follow-host)")
flag.IntVar(&f.rows, "rows", defaultRows, "PTY rows (overridden by host size if -follow-host)")
flag.IntVar(&f.idleMS, "dump-after-idle", defaultIdleMS, "dump grid to stderr after this many ms of PTY silence (0 disables)")
flag.BoolVar(&f.followHost, "follow-host", true, "use the host terminal's size and follow SIGWINCH")
flag.BoolVar(&f.noPassthrough, "no-stdin", false, "don't forward host stdin to the child PTY")
flag.StringVar(&f.bytesPath, "bytes-out", "", "tee raw PTY bytes to this file (default: spike-<pid>.bytes when child starts)")
flag.StringVar(&f.gridPath, "grid-out", "", "write grid dumps to this file (default: spike-<pid>.grid.log). Use - for stderr (will visually corrupt alt-screen TUIs).")
flag.BoolVar(&f.gridToStderr, "grid-stderr", false, "also echo each grid dump to stderr. Convenient for non-TUI children (echo, bash); avoid with vim/htop/agent CLIs.")
flag.StringVar(&f.hotkey, "hotkey", "ctrl-]", "key chord that triggers a grid dump: ctrl-], ctrl-\\, ctrl-^, ctrl-_, ctrl-t, ctrl-o, ctrl-space")
flag.BoolVar(&f.debugStdin, "debug-stdin", false, "log every stdin byte to stderr as we read it (for working out what your terminal sends)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: spike [flags] -- <argv>\n\nflags:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nWhile running, press the configured -hotkey to dump the grid.\nDefault sink is spike-<pid>.grid.log; tail -f it in another terminal.\n")
}
flag.Parse()
argv := flag.Args()
if len(argv) == 0 {
flag.Usage()
os.Exit(2)
}
hotkey, ok := hotkeyAliases[strings.ToLower(f.hotkey)]
if !ok {
fmt.Fprintf(os.Stderr, "spike: unknown -hotkey %q (see -h for options)\n", f.hotkey)
os.Exit(2)
}
startCols, startRows := uint16(f.cols), uint16(f.rows)
if f.followHost {
if c, r, ok := hostSize(); ok {
startCols, startRows = c, r
}
}
if err := run(argv, startCols, startRows, f.idleMS, f.followHost, !f.noPassthrough, f.bytesPath, f.gridPath, f.gridToStderr, hotkey, f.debugStdin); err != nil {
fmt.Fprintf(os.Stderr, "spike: %v\n", err)
os.Exit(1)
}
}
func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthrough bool, bytesPath, gridPath string, gridToStderr bool, hotkey byte, debugStdin bool) error {
em, err := vt.NewGhosttyEmulator(cols, rows)
if err != nil {
return fmt.Errorf("emulator: %w", err)
}
defer em.Close()
child, err := pty.Start(argv, nil, cols, rows)
if err != nil {
return fmt.Errorf("pty: %w", err)
}
defer child.Close()
// Wire WRITE_PTY back to the child's stdin so DA/DSR query responses
// reach the program asking.
em.OnWritePTY(func(b []byte) {
if _, werr := child.Write(b); werr != nil {
fmt.Fprintf(os.Stderr, "\r\nspike: write_pty back to child failed: %v\r\n", werr)
}
})
// Set up the bytes tee.
if bytesPath == "" {
bytesPath = fmt.Sprintf("spike-%d.bytes", child.Pid())
}
bytesFile, err := os.Create(bytesPath)
if err != nil {
return fmt.Errorf("bytes tee: %w", err)
}
defer bytesFile.Close()
// Set up the grid sink. By default this is a file, not stderr, because
// writing a multi-line dump to the host terminal while an alt-screen TUI
// owns it visually corrupts the host display (the TUI inside the PTY is
// fine; libghostty-vt's grid is fine; only the host's render breaks).
var gridSink *os.File
gridIsStderr := false
switch gridPath {
case "-":
gridSink = os.Stderr
gridIsStderr = true
case "":
gridPath = fmt.Sprintf("spike-%d.grid.log", child.Pid())
fallthrough
default:
gridSink, err = os.Create(gridPath)
if err != nil {
return fmt.Errorf("grid log: %w", err)
}
defer gridSink.Close()
}
fmt.Fprintf(os.Stderr, "spike: child pid=%d, bytes=%s, grid=%s (%dx%d)\r\n",
child.Pid(), bytesPath, gridPath, cols, rows)
if !gridIsStderr {
fmt.Fprintf(os.Stderr, "spike: tail -f %s in another terminal to watch dumps live\r\n", gridPath)
}
// Set host stdin to raw mode so key sequences (arrows, Ctrl-C, etc.)
// reach the child intact. Save the state for restore.
var restoreState *term.State
if stdinPassthrough && term.IsTerminal(int(os.Stdin.Fd())) {
st, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("stdin raw: %w", err)
}
restoreState = st
defer term.Restore(int(os.Stdin.Fd()), restoreState)
}
// Idle detection: PTY reader updates lastWrite; ticker checks if we
// crossed the threshold without writes and prints a grid dump.
var lastWriteNS atomic.Int64
lastWriteNS.Store(time.Now().UnixNano())
var lastDumpNS atomic.Int64
var dumpRequest = make(chan string, 4)
// Coordinated shutdown.
var wg sync.WaitGroup
done := make(chan struct{})
closeDone := sync.OnceFunc(func() { close(done) })
// Reader: PTY -> stdout passthrough + emulator + bytes tee.
wg.Add(1)
go func() {
defer wg.Done()
defer closeDone()
buf := make([]byte, readBufferBytes)
for {
n, rerr := child.Read(buf)
if n > 0 {
chunk := buf[:n]
// Tee to host stdout so the user can see the TUI normally.
_, _ = os.Stdout.Write(chunk)
// Tee to bytes file for golden replay.
_, _ = bytesFile.Write(chunk)
// Feed the emulator.
if _, werr := em.Write(chunk); werr != nil {
fmt.Fprintf(os.Stderr, "\r\nspike: emulator.Write error: %v\r\n", werr)
}
lastWriteNS.Store(time.Now().UnixNano())
}
if rerr != nil {
// EIO from the PTY master is the normal "child closed its
// side" signal on Linux; treat it like EOF.
if rerr != io.EOF && !errors.Is(rerr, syscall.EIO) {
fmt.Fprintf(os.Stderr, "\r\nspike: pty read: %v\r\n", rerr)
}
return
}
}
}()
// Writer: stdin -> PTY, watching for the dump hotkey.
if stdinPassthrough {
wg.Add(1)
go func() {
defer wg.Done()
buf := make([]byte, 1024)
for {
select {
case <-done:
return
default:
}
n, rerr := os.Stdin.Read(buf)
if n > 0 {
chunk := buf[:n]
if debugStdin {
fmt.Fprintf(os.Stderr, "\r\nspike[debug-stdin]: %d bytes:", n)
for _, b := range chunk {
fmt.Fprintf(os.Stderr, " %02x", b)
}
fmt.Fprintf(os.Stderr, "\r\n")
}
out := make([]byte, 0, len(chunk))
for _, b := range chunk {
if b == hotkey {
select {
case dumpRequest <- "hotkey":
default:
// channel full; user is mashing the hotkey,
// dumps are still coming
}
continue
}
out = append(out, b)
}
if len(out) > 0 {
if _, werr := child.Write(out); werr != nil {
return
}
}
}
if rerr != nil {
return
}
}
}()
}
// SIGWINCH propagation.
if followHost {
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
wg.Add(1)
go func() {
defer wg.Done()
defer signal.Stop(winch)
for {
select {
case <-done:
return
case <-winch:
if c, r, ok := hostSize(); ok {
_ = child.Resize(c, r)
_ = em.Resize(c, r)
}
}
}
}()
}
// Idle ticker: enqueue an "idle" dump request when crossing the threshold.
if idleMS > 0 {
wg.Add(1)
go func() {
defer wg.Done()
tick := time.NewTicker(time.Duration(idleMS) * time.Millisecond / 4)
defer tick.Stop()
for {
select {
case <-done:
return
case <-tick.C:
now := time.Now().UnixNano()
lw := lastWriteNS.Load()
ld := lastDumpNS.Load()
if now-lw >= int64(idleMS)*int64(time.Millisecond) && lw > ld {
lastDumpNS.Store(now)
dumpRequest <- "idle"
}
}
}
}()
}
// Dump worker: serialises grid reads. PlainText is not cheap and we
// don't want overlapping calls.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
case reason := <-dumpRequest:
dumpGrid(em, reason, gridSink, gridIsStderr || gridToStderr)
}
}
}()
// Wait for the child to exit, then close everything down.
exitErr := child.Wait()
closeDone()
wg.Wait()
// Final dump for the record.
dumpGrid(em, "final", gridSink, gridIsStderr || gridToStderr)
if restoreState != nil {
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
}
fmt.Fprintf(os.Stderr, "spike: child exited (%v); bytes=%s grid=%s\r\n", exitErr, bytesPath, gridPath)
return nil
}
// dumpGrid renders the emulator's active screen and writes it to sink.
//
// When sinkIsTTY is true the lines are terminated with CRLF so they render
// correctly even when stdin is in raw mode. Otherwise we use plain LF —
// log files don't want CRs.
func dumpGrid(em *vt.GhosttyEmulator, reason string, sink *os.File, sinkIsTTY bool) {
txt, err := em.PlainText()
if err != nil {
fmt.Fprintf(os.Stderr, "\r\nspike: PlainText (%s): %v\r\n", reason, err)
return
}
cur, _ := em.Cursor()
scr, _ := em.ActiveScreen()
screenName := "primary"
if scr == vt.ScreenAlternate {
screenName = "alternate"
}
eol := "\n"
if sinkIsTTY {
eol = "\r\n"
}
sep := strings.Repeat("-", 78)
header := fmt.Sprintf("[grid dump: %s @ %s | screen=%s cursor=(%d,%d) visible=%v]",
reason, time.Now().Format(time.RFC3339Nano), screenName, cur.Col, cur.Row, cur.Visible)
fmt.Fprintf(sink, "%s%s%s%s%s%s", eol, sep, eol, header, eol, sep+eol)
for _, line := range strings.Split(txt, "\n") {
fmt.Fprintf(sink, "%s%s", line, eol)
}
fmt.Fprintf(sink, "%s%s", sep, eol)
// If the sink is a file (not the host TTY), print a one-line breadcrumb
// to stderr so the user knows the hotkey fired — but only for explicit
// user-triggered dumps (hotkey), and only when the child is on the
// primary screen. Skipping idle/final dumps keeps the host terminal
// quiet during normal interactive use.
if !sinkIsTTY && reason == "hotkey" && scr != vt.ScreenAlternate {
fmt.Fprintf(os.Stderr, "\r\nspike: dumped grid -> %s\r\n", sink.Name())
}
}
func hostSize() (cols, rows uint16, ok bool) {
ws, err := cpty.GetsizeFull(os.Stdin)
if err != nil {
return 0, 0, false
}
if ws.Cols == 0 || ws.Rows == 0 {
return 0, 0, false
}
return ws.Cols, ws.Rows, true
}

82
cmd/spike/testdata/run-matrix.sh vendored Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Spike test matrix. Runs ./bin/spike against each target in sequence so a
# human can confirm by eye that the grid dump matches what the program would
# render in their own terminal.
#
# Run this from the project root:
# make spike
# ./cmd/spike/testdata/run-matrix.sh
#
# Each target's raw PTY recording lands in spike-<pid>.bytes and the final
# grid dump is appended to spike-report-<timestamp>.txt.
set -u
ROOT=$(cd "$(dirname "$0")/../../.." && pwd)
BIN=${SPIKE_BIN:-$ROOT/bin/spike}
REPORT=$ROOT/spike-report-$(date +%Y%m%dT%H%M%S).txt
if [[ ! -x "$BIN" ]]; then
echo "spike binary not found at $BIN — run 'make spike' first" >&2
exit 1
fi
note() { printf '\n=== %s ===\n' "$*" | tee -a "$REPORT"; }
have() { command -v "$1" >/dev/null 2>&1; }
run_case() {
local label=$1; shift
note "$label"
printf 'argv: %q ' "$@" | tee -a "$REPORT"
printf '\n'
printf 'Press Ctrl-] in the spike to dump the grid.\n'
printf 'Grid dumps go to spike-<pid>.grid.log (tail -f in another terminal to watch live).\n'
printf 'The child must exit cleanly to advance.\n'
printf 'Press Enter when ready to start this case (or s to skip)... '
read -r ans
if [[ "$ans" == "s" ]]; then
echo 'skipped' | tee -a "$REPORT"
return
fi
# Tee spike's stderr so grid dumps are visible AND captured in the report.
# Without this, Ctrl-] dumps end up in $REPORT silently and look like
# the hotkey did nothing.
"$BIN" -- "$@" 2> >(tee -a "$REPORT" >&2)
echo "---" >> "$REPORT"
}
note "spike test matrix — $(date -Is)"
"$BIN" -h 2>>"$REPORT" || true
# 1. Trivial stream-mode sanity check.
run_case "echo + sleep (stream sanity)" sh -c 'echo hello; sleep 1'
# 2. Interactive shell.
run_case "bash -i (prompt + history)" bash -i
# 3. Alt-screen line editor.
if have vim; then
run_case "vim README.md (alt-screen)" vim "$ROOT/SPEC.md"
else
echo "skip vim: not installed" | tee -a "$REPORT"
fi
# 4. Alt-screen continuous redraw.
if have htop; then
run_case "htop (alt-screen, redraw)" htop
else
echo "skip htop: not installed" | tee -a "$REPORT"
fi
# 57. Real targets — the actual point of the spike.
for agent in claude opencode codex; do
if have "$agent"; then
run_case "$agent (real target)" "$agent"
else
echo "skip $agent: not installed" | tee -a "$REPORT"
fi
done
note "matrix complete. report: $REPORT"
echo
echo "Next step: write up the per-target verdict in SPIKE-REPORT.md."