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

724
internal/app/app.go Normal file
View File

@@ -0,0 +1,724 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
cpty "github.com/creack/pty"
"golang.org/x/term"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/policy"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad"
)
// Options configures a patterm run.
type Options struct {
ProjectDir string
ProjectKey string
}
const keyCtrlK byte = 0x0b
// Run is patterm's single-process entry point. SPEC §2: one Go process
// owns everything; no daemon, no detach, no socket-based reattachment.
func Run(ctx context.Context, opts Options) error {
if opts.ProjectDir == "" {
return errors.New("app: ProjectDir required")
}
presets, err := preset.Load()
if err != nil {
return fmt.Errorf("app: load presets: %w", err)
}
pol, err := policy.Load()
if err != nil {
return fmt.Errorf("app: load policy: %w", err)
}
// Ensure the per-project scratchpad dir exists so MCP and the UI
// can read/write into it. SPEC §3.
pads, err := scratchpad.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: scratchpad init: %w", err)
}
// In-process MCP server bound to the per-PID socket. Children that
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
// SPEC §10.
mcpSrv, err := mcp.Start()
if err != nil {
return fmt.Errorf("app: mcp start: %w", err)
}
defer mcpSrv.Close()
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
defer sess.Shutdown()
cols, rows := hostSize()
layout := newTerminalLayout(cols, rows)
// Launcher handles preset → child translation, including MCP
// config injection for agent presets.
launcher := NewLauncher(sess, mcpSrv.Socket(), layout.childCols(), layout.childRows())
// Wire the tool host into MCP. Spawns through MCP use the host
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
// resize them later.
host := newToolHost(sess, pads, launcher, presets, pol, layout.childCols(), layout.childRows())
mcpSrv.SetHost(host)
var restoreState *term.State
if term.IsTerminal(int(os.Stdin.Fd())) {
st, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("app: stdin raw: %w", err)
}
restoreState = st
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
st := &uiState{
sess: sess,
presets: presets,
launcher: launcher,
pads: pads,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
}
host.attention = st
st.lastExit.Store(-1)
sess.Subscribe(st)
st.enterScreen()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
// Set initial PTY grid for any future child. The child gets the
// computed main viewport, excluding tab bar, sidebar, and status.
sess.ResizeAll(layout.childCols(), layout.childRows())
launcher.SetSize(layout.childCols(), layout.childRows())
host.SetSize(layout.childCols(), layout.childRows())
var wg sync.WaitGroup
// SIGWINCH.
wg.Add(1)
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
go func() {
defer wg.Done()
defer signal.Stop(winch)
for {
select {
case <-ctx.Done():
return
case <-winch:
c, r := hostSize()
if c == 0 || r == 0 {
continue
}
st.dimsMu.Lock()
st.hostCols, st.hostRows = c, r
l := st.layoutLocked()
st.dimsMu.Unlock()
st.mu.Lock()
if st.renderer != nil {
st.renderer.SetLayout(l)
}
st.mu.Unlock()
sess.ResizeAll(l.childCols(), l.childRows())
launcher.SetSize(l.childCols(), l.childRows())
host.SetSize(l.childCols(), l.childRows())
st.clearScreen()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
}
}()
// External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit).
wg.Add(1)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
go func() {
defer wg.Done()
defer signal.Stop(sigCh)
select {
case <-ctx.Done():
return
case sig := <-sigCh:
st.dbgf("signal %s; tearing down", sig)
cancel()
}
}()
// Stdin loop.
go func() {
if err := st.stdinLoop(); err != nil {
st.dbgf("stdinLoop: %v", err)
}
cancel()
}()
<-ctx.Done()
wg.Wait()
st.leaveScreen()
if restoreState != nil {
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
}
if st.lastExit.Load() >= 0 {
fmt.Fprintf(os.Stderr, "patterm: last child exited (%d).\n", st.lastExit.Load())
}
return nil
}
// uiState is the shared state between the SIGWINCH loop, the stdin
// loop, and the session listener callbacks.
type uiState struct {
sess *Session
presets preset.Set
launcher *Launcher
pads *scratchpad.Store
outMu sync.Mutex
mu sync.Mutex
palette *paletteState
focusedID string
focusedName string
// renderer confines focused-child live output to the main viewport.
// A fresh renderer is allocated per focused child so partial-escape
// state cannot bleed between panes.
renderer *viewportRenderer
// passthrough: when true, the next keystroke is forwarded to the
// focused PTY untouched (SPEC §4 Ctrl-K Ctrl-K).
passthroughArmed bool
// attention is the latest request_human_attention surfaced via MCP;
// rendered in the status line until cleared.
attentionText string
attentionAt string
dimsMu sync.Mutex
hostCols, hostRows uint16
stdinTTY bool
lastExit atomic.Int32
}
func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...)
}
// notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most
// recent ask so the status line keeps showing it. The sidebar-blink is
// deferred until the §4 chrome lands.
func (st *uiState) notifyAttention(childID, reason string) {
c := st.sess.FindChild(childID)
name := childID
if c != nil {
name = c.Name
}
st.mu.Lock()
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
st.attentionAt = childID
st.mu.Unlock()
st.drawStatusLine()
}
// OnChildSpawned auto-focuses the new child.
func (st *uiState) OnChildSpawned(c *Child) {
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
st.moveToViewportOrigin()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// OnChildExited drops focus and shows the empty state if it was the
// focused child.
func (st *uiState) OnChildExited(c *Child) {
st.lastExit.Store(int32(c.ExitCode()))
st.mu.Lock()
if c.ID == st.focusedID {
next := firstRunningTopLevel(st.sess.Children())
if next == nil {
st.focusedID = ""
st.focusedName = ""
st.renderEmptyStateLocked()
} else {
st.focusedID = next.ID
st.focusedName = next.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
}
}
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
if st.focusedID != "" {
st.repaintFocused()
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// OnPTYOut writes live output for the focused child when the palette is
// not covering the screen. The viewport renderer shifts cursor movement
// into the main pane and rewrites destructive clears. Host autowrap is
// disabled only around the replay so long styled runs cannot wrap into
// the right rail.
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.mu.Lock()
focus := st.focusedID
palOpen := st.palette != nil
renderer := st.renderer
st.mu.Unlock()
if palOpen || focus != childID || renderer == nil {
return
}
out := renderer.Render(chunk)
st.outMu.Lock()
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
_, _ = os.Stdout.Write(out)
_, _ = os.Stdout.Write([]byte("\x1b[?7h"))
st.outMu.Unlock()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) enterScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h"))
}
func (st *uiState) leaveScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[?1049l"))
}
func (st *uiState) clearScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
}
func (st *uiState) moveToViewportOrigin() {
layout := st.layoutSnapshot()
st.outMu.Lock()
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b[%d;%dH", int(layout.mainTop), int(layout.mainLeft))
}
func (st *uiState) renderPaletteLocked() {
if st.palette == nil {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
cols, rows := st.hostSizeSnapshot()
st.palette.render(wrapWriter(os.Stdout), int(cols), int(rows))
}
// drawStatusLine renders SPEC §4's bottom status line. Left side: input
// ownership toast ("orchestrator driving" / "you have control") and any
// attention ask. Right side: palette hint. The PTY child occupies
// host_rows-1 rows so this row is exclusively ours.
func (st *uiState) drawStatusLine() {
st.mu.Lock()
palOpen := st.palette != nil
focusID := st.focusedID
focusName := st.focusedName
attention := st.attentionText
attentionAt := st.attentionAt
st.mu.Unlock()
if palOpen {
return
}
cols, rows := st.hostSizeSnapshot()
if cols == 0 || rows == 0 {
return
}
owner := ""
if focusID != "" {
if c := st.sess.FindChild(focusID); c != nil {
switch c.Owner() {
case OwnerOrchestrator:
owner = "orchestrator driving"
case OwnerUser:
owner = "you have control"
}
}
}
left := ""
if focusName != "" {
left = focusName
}
if owner != "" {
if left != "" {
left = left + " · " + owner
} else {
left = owner
}
}
if attention != "" && attentionAt == focusID {
left = "[!] " + attention
}
right := "Ctrl-K · palette"
pad := int(cols) - len(left) - len(right)
if pad < 1 {
pad = 1
}
line := left + strings.Repeat(" ", pad) + right
if len(line) > int(cols) {
line = line[:int(cols)]
}
st.outMu.Lock()
defer st.outMu.Unlock()
// Save cursor, move to last row col 1, write, restore.
fmt.Fprintf(os.Stdout, "\x1b7\x1b[999;1H\x1b[2m\x1b[7m%s\x1b[0m\x1b8", line)
}
// renderEmptyState is the SPEC §4 blank-canvas hint. Drawn whenever no
// child is focused.
func (st *uiState) renderEmptyState() {
st.mu.Lock()
defer st.mu.Unlock()
st.renderEmptyStateLocked()
}
func (st *uiState) renderEmptyStateLocked() {
st.outMu.Lock()
defer st.outMu.Unlock()
layout := st.layoutSnapshot()
line := "Press Ctrl-K to spawn an agent or process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
if row < int(layout.mainTop) {
row = int(layout.mainTop)
}
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
}
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
st.dimsMu.Lock()
defer st.dimsMu.Unlock()
return st.hostCols, st.hostRows
}
func (st *uiState) layoutSnapshot() terminalLayout {
st.dimsMu.Lock()
defer st.dimsMu.Unlock()
return st.layoutLocked()
}
func (st *uiState) layoutLocked() terminalLayout {
return newTerminalLayout(st.hostCols, st.hostRows)
}
func (st *uiState) stdinLoop() error {
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
st.processStdin(buf[:n])
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("read error %w (n=%d)", err, n)
}
}
}
// processStdin walks one read of stdin byte by byte. The palette
// intercepts everything when it's open. Otherwise Ctrl-K opens it and
// every other byte forwards to the focused PTY. The Ctrl-K Ctrl-K chord
// is SPEC §4's passthrough prefix: after the first Ctrl-K, if the very
// next byte is another Ctrl-K, both are sent to the PTY literally.
//
// NOTE on locking: a palette-close action (spawn / switch / kill /
// quit) may fire session listeners (OnChildSpawned, OnChildExited)
// synchronously. Those listeners need st.mu. We must NOT hold st.mu
// when calling closePalette — bytes after the action in the same chunk
// are dropped on the floor, which is the right behavior anyway (the
// user just decided the prior pane is gone).
func (st *uiState) processStdin(chunk []byte) {
st.mu.Lock()
forward := make([]byte, 0, len(chunk))
flushForward := func() {
if len(forward) == 0 {
return
}
if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner()
_ = c.InjectAsUser(forward)
if prev != OwnerUser {
go st.drawStatusLine()
}
}
}
forward = forward[:0]
}
var pendingAction *paletteAction
i := 0
for i < len(chunk) {
b := chunk[i]
// Passthrough armed: forward this byte literally regardless of
// what it is, then disarm.
if st.passthroughArmed {
forward = append(forward, b)
st.passthroughArmed = false
i++
continue
}
// Palette mode swallows all bytes.
if st.palette != nil {
var peek []byte
if i+1 < len(chunk) {
peek = chunk[i+1:]
}
action, done := st.palette.handleKey(b, peek)
if b == 0x1b && len(peek) >= 2 && peek[0] == '[' {
if peek[1] == 'A' || peek[1] == 'B' {
i += 3
} else {
i++
}
} else {
i++
}
if done {
a := action
pendingAction = &a
break
}
st.renderPaletteLocked()
continue
}
// Ctrl-K is the reserved app-level binding. Two cases:
// - Ctrl-K then anything except Ctrl-K → open palette.
// - Ctrl-K Ctrl-K → arm passthrough; the next byte goes raw.
if b == keyCtrlK {
// Peek at the next byte if we have it.
next := byte(0)
haveNext := i+1 < len(chunk)
if haveNext {
next = chunk[i+1]
}
if haveNext && next == keyCtrlK {
// Chord: forward both Ctrl-K bytes literally. (Some
// nested TUIs expect Ctrl-K itself.)
flushForward()
forward = append(forward, keyCtrlK, keyCtrlK)
flushForward()
i += 2
continue
}
if !haveNext {
// Could be the first byte of a chord — arm and wait.
st.passthroughArmed = true
// But we also want palette-open on a lone Ctrl-K. Resolve
// by treating "Ctrl-K at end of read" as palette open;
// any subsequent Ctrl-K in the next read still has the
// chord semantics because passthroughArmed got set first.
// To match the spec's reading, simpler model: lone Ctrl-K
// in this read opens the palette.
st.passthroughArmed = false
flushForward()
st.openPaletteLocked()
i++
continue
}
// Ctrl-K followed by something that's not Ctrl-K → palette open.
flushForward()
st.openPaletteLocked()
i++
continue
}
forward = append(forward, b)
i++
}
flushForward()
st.mu.Unlock()
if pendingAction != nil {
st.closePalette(*pendingAction)
}
}
func (st *uiState) openPaletteLocked() {
st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets)
st.renderPaletteLocked()
}
// closePalette is invoked with st.mu UNLOCKED. The session-mutating
// actions below (spawn / kill) fire listeners that take st.mu, so
// holding it here would deadlock. Each helper this calls takes its own
// brief mu acquisitions as needed.
func (st *uiState) closePalette(action paletteAction) {
st.mu.Lock()
st.palette = nil
st.mu.Unlock()
st.clearScreen()
switch action.kind {
case "", "cancel":
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "spawn-agent":
if action.preset == nil {
st.repaintFocused()
return
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
// LaunchAgent fires OnChildSpawned synchronously; it will draw
// chrome and set focus.
if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", ""); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "spawn-process":
if action.preset == nil {
st.repaintFocused()
return
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchProcess(action.preset, action.preset.Name); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "switch":
c := st.sess.FindChild(action.childID)
if c == nil || c.Status() != StatusRunning {
st.repaintFocused()
return
}
st.mu.Lock()
st.focusedID = action.childID
st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
st.mu.Unlock()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "kill":
_ = st.sess.Kill(action.childID, syscall.SIGTERM)
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "quit":
st.requestExit()
}
}
// flashError surfaces a spawn/etc. failure in the status line until the
// next attention update overwrites it. stderr is hidden under the alt
// screen so we can't rely on Fprintln(os.Stderr).
func (st *uiState) flashError(msg string) {
st.mu.Lock()
st.attentionText = msg
st.attentionAt = "" // shows on every focus until cleared
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// repaintFocused redraws the current focused child's screen snapshot.
// Callers must NOT hold st.mu — repaintFocused takes it
// briefly itself.
func (st *uiState) repaintFocused() {
st.mu.Lock()
id := st.focusedID
st.mu.Unlock()
if id == "" {
st.renderEmptyState()
return
}
text, cursor, err := st.sess.SnapshotChild(id)
if err != nil {
return
}
out := renderScreenSnapshot(text, cursor, st.layoutSnapshot())
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
}
func (st *uiState) requestExit() {
// Reuse SIGTERM-to-self as the cleanest way to unwind: the signal
// handler in Run() calls cancel() which exits the loop and runs
// Shutdown.
_ = syscall.Kill(os.Getpid(), syscall.SIGTERM)
}
func hostSize() (cols, rows uint16) {
ws, err := cpty.GetsizeFull(os.Stdin)
if err != nil || ws.Cols == 0 || ws.Rows == 0 {
return 120, 40
}
return ws.Cols, ws.Rows
}

235
internal/app/child.go Normal file
View File

@@ -0,0 +1,235 @@
package app
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os/exec"
"sync"
"sync/atomic"
"syscall"
"time"
pkgpty "github.com/harrybrwn/patterm/internal/pty"
"github.com/harrybrwn/patterm/internal/vt"
)
type ChildStatus string
const (
StatusRunning ChildStatus = "running"
StatusExited ChildStatus = "exited"
StatusErrored ChildStatus = "errored"
)
// ChildKind matches the two preset flavours in SPEC §10.
type ChildKind string
const (
KindAgent ChildKind = "agent"
KindProcess ChildKind = "process"
)
// Owner reflects the SPEC §6 input-ownership flag.
type Owner string
const (
OwnerUser Owner = "user"
OwnerOrchestrator Owner = "orchestrator"
)
// Child is one PTY-backed process plus its emulator. The same struct
// represents both agent presets (with MCP) and process presets (raw).
type Child struct {
ID string
Name string
Argv []string
Kind ChildKind
ParentID string // empty for top-level sessions
// Identity is the per-spawn token the mcp-stdio proxy uses to
// identify itself when calling tools. Empty for process presets.
Identity string
pty *pkgpty.PTY
em *vt.GhosttyEmulator
status atomic.Pointer[ChildStatus]
exitCode atomic.Int32
owner atomic.Pointer[Owner]
// lastWrite is the wall time of the most recent PTY-master write.
// SPEC §11 idle heuristic: a pane is idle once nothing has been
// written for the preset's threshold (default 1s).
lastWriteNS atomic.Int64
// ringMu guards ring. The ring buffer carries the last `ringCap`
// bytes the PTY produced, used by SPEC §7 read_output stream mode.
ringMu sync.Mutex
ring []byte
ringStart int64 // absolute offset of ring[0]
ringWrites int64 // cumulative bytes written
}
const ringCap = 1 << 20 // 1 MiB per SPEC §5
func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
if len(argv) == 0 {
return nil, errors.New("child: empty argv")
}
em, err := vt.NewGhosttyEmulator(cols, rows)
if err != nil {
return nil, fmt.Errorf("child %s emulator: %w", id, err)
}
p, err := pkgpty.Start(argv, env, cols, rows)
if err != nil {
em.Close()
return nil, fmt.Errorf("child %s pty: %w", id, err)
}
c := &Child{
ID: id,
Name: name,
Argv: argv,
Kind: kind,
ParentID: parentID,
pty: p,
em: em,
ring: make([]byte, 0, ringCap),
}
st := StatusRunning
c.status.Store(&st)
c.exitCode.Store(-1)
// Agents spawned by an orchestrator default to orchestrator-owned;
// everything else (top-level, processes) defaults to user. SPEC §6.
def := OwnerUser
if kind == KindAgent && parentID != "" {
def = OwnerOrchestrator
}
c.owner.Store(&def)
if kind == KindAgent {
c.Identity = mintIdentity()
}
em.OnWritePTY(func(b []byte) {
_, _ = p.Write(b)
})
return c, nil
}
func (c *Child) Status() ChildStatus {
st := c.status.Load()
if st == nil {
return StatusRunning
}
return *st
}
func (c *Child) ExitCode() int { return int(c.exitCode.Load()) }
func (c *Child) PID() int { return c.pty.Pid() }
func (c *Child) Owner() Owner {
o := c.owner.Load()
if o == nil {
return OwnerUser
}
return *o
}
func (c *Child) SetOwner(o Owner) { c.owner.Store(&o) }
// IdleMS returns how many milliseconds since the last PTY write.
// 0 means "no writes yet". SPEC §11.
func (c *Child) IdleMS() int64 {
last := c.lastWriteNS.Load()
if last == 0 {
return 0
}
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.ringMu.Lock()
defer c.ringMu.Unlock()
c.ring = append(c.ring, chunk...)
c.ringWrites += int64(len(chunk))
if len(c.ring) > ringCap {
drop := len(c.ring) - ringCap
c.ring = c.ring[drop:]
c.ringStart += int64(drop)
}
}
// StreamRead returns ring bytes from `since` to the current write head,
// plus the new offset. Offsets are absolute (cumulative bytes ever
// written). If `since` is before the ring start, the caller missed
// data; we return what we have and the new offset.
func (c *Child) StreamRead(since int64) ([]byte, int64) {
c.ringMu.Lock()
defer c.ringMu.Unlock()
if since < c.ringStart {
since = c.ringStart
}
end := c.ringStart + int64(len(c.ring))
if since >= end {
return nil, end
}
start := int(since - c.ringStart)
out := make([]byte, end-since)
copy(out, c.ring[start:])
return out, end
}
func (c *Child) signal(sig syscall.Signal) error {
pid := c.pty.Pid()
if pid <= 0 {
return errors.New("child has no pid")
}
if err := syscall.Kill(-pid, sig); err == nil {
return nil
}
return syscall.Kill(pid, sig)
}
func (c *Child) markExited(err error) {
exitCode := int32(0)
st := StatusExited
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
exitCode = int32(ee.ExitCode())
} else {
exitCode = -1
st = StatusErrored
}
}
c.exitCode.Store(exitCode)
c.status.Store(&st)
}
// InjectAsUser is the path the human takes when typing in the focused
// pane. SPEC §6: the user's first keystroke flips ownership.
func (c *Child) InjectAsUser(b []byte) error {
c.SetOwner(OwnerUser)
_, err := c.pty.Write(b)
return err
}
// InjectAsOrchestrator is the path send_message_to / report_to_parent /
// initial_prompt / timer_wait writes take. Ownership flips back to
// orchestrator. SPEC §6.
func (c *Child) InjectAsOrchestrator(b []byte) error {
c.SetOwner(OwnerOrchestrator)
_, err := c.pty.Write(b)
return err
}
func mintIdentity() string {
var buf [12]byte
_, _ = rand.Read(buf[:])
return hex.EncodeToString(buf[:])
}

267
internal/app/cursorshift.go Normal file
View File

@@ -0,0 +1,267 @@
package app
import (
"strconv"
"strings"
)
// cursorShifter rewrites cursor-positioning ANSI escapes in a PTY byte
// stream so the child's "row 1" becomes the host's "row 1+offset".
// This lets patterm reserve top rows for chrome (SPEC §4 tab bar)
// while keeping the child unaware.
//
// Sequences rewritten:
// - CSI H, CSI <r> H, CSI <r>;<c> H — CUP
// - CSI <r>;<c> f — HVP
// - CSI <n> d — VPA (line position absolute)
// - CSI <t>;<b> r — DECSTBM (scrolling region)
//
// Other sequences (SGR, mode set, OSC titles, DCS, alt-screen toggles)
// are forwarded byte-for-byte. The parser tracks OSC/DCS/SOS/PM/APC
// state so byte sequences inside those wrappers are NOT misread as
// CSI commands.
type cursorShifter struct {
rowOffset int
state shifterState
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
csiPrefix []byte // private prefix bytes (?, >, =) after CSI
pending strings.Builder
}
type shifterState int
const (
stNormal shifterState = iota
stEsc
stCSI
stCSIPrefix // CSI <private-prefix>... — private prefix means we DON'T rewrite
stOSC
stOSCEsc // we saw ESC inside OSC; expect '\' to close ST
stDCS
stDCSEsc
stSOSPMAPC // SOS/PM/APC body — terminator is ESC \
stSOSPMAPCEsc
)
func newCursorShifter(rowOffset int) *cursorShifter {
return &cursorShifter{rowOffset: rowOffset}
}
func (cs *cursorShifter) SetRowOffset(off int) {
cs.rowOffset = off
}
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
// any complete CUP/HVP/VPA/DECSTBM sequences, and returns the rewritten
// bytes. Partial sequences are buffered across calls so a CSI that
// straddles two PTY reads still gets rewritten.
func (cs *cursorShifter) Shift(in []byte) []byte {
cs.pending.Reset()
for _, b := range in {
cs.feed(b)
}
out := cs.pending.String()
return []byte(out)
}
func (cs *cursorShifter) feed(b byte) {
switch cs.state {
case stNormal:
if b == 0x1b {
cs.state = stEsc
cs.buf = cs.buf[:0]
cs.buf = append(cs.buf, b)
return
}
cs.pending.WriteByte(b)
case stEsc:
cs.buf = append(cs.buf, b)
switch b {
case '[':
cs.state = stCSI
cs.csiPrefix = cs.csiPrefix[:0]
case ']':
cs.state = stOSC
case 'P':
cs.state = stDCS
case 'X', '^', '_':
cs.state = stSOSPMAPC
default:
// Two-byte ESC sequence: ESC <something>. Forward as-is.
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
}
case stCSI:
// First non-param byte after CSI might be a private prefix
// (?, >, =, etc., 0x3c..0x3f). If so, switch to CSIPrefix and
// don't rewrite this sequence.
if len(cs.csiPrefix) == 0 && len(cs.buf) == 2 && b >= 0x3c && b <= 0x3f {
cs.csiPrefix = append(cs.csiPrefix, b)
cs.buf = append(cs.buf, b)
cs.state = stCSIPrefix
return
}
cs.buf = append(cs.buf, b)
if isCSIFinal(b) {
cs.emitCSI()
cs.state = stNormal
cs.buf = cs.buf[:0]
}
case stCSIPrefix:
cs.buf = append(cs.buf, b)
if isCSIFinal(b) {
// Private CSI; forward unchanged.
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
}
case stOSC:
cs.buf = append(cs.buf, b)
switch b {
case 0x07: // BEL
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
case 0x1b:
cs.state = stOSCEsc
}
case stOSCEsc:
cs.buf = append(cs.buf, b)
// ESC \ terminates ST.
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
case stDCS:
cs.buf = append(cs.buf, b)
if b == 0x1b {
cs.state = stDCSEsc
}
case stDCSEsc:
cs.buf = append(cs.buf, b)
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
case stSOSPMAPC:
cs.buf = append(cs.buf, b)
if b == 0x1b {
cs.state = stSOSPMAPCEsc
}
case stSOSPMAPCEsc:
cs.buf = append(cs.buf, b)
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
}
}
// emitCSI writes the buffered CSI sequence to pending, rewriting row
// coordinates for CUP/HVP/VPA/DECSTBM.
func (cs *cursorShifter) emitCSI() {
// cs.buf is ESC [ <params...> <final>. Slice out params + final.
if len(cs.buf) < 3 {
cs.pending.Write(cs.buf)
return
}
final := cs.buf[len(cs.buf)-1]
paramsRaw := cs.buf[2 : len(cs.buf)-1]
// Intermediate bytes can appear before the final (rare). Skip
// rewriting if any are present.
for _, b := range paramsRaw {
if b >= 0x20 && b <= 0x2f {
cs.pending.Write(cs.buf)
return
}
}
switch final {
case 'H', 'f':
// CUP/HVP: r;c (both default 1).
r, c, ok := parseTwoParams(paramsRaw)
if !ok {
cs.pending.Write(cs.buf)
return
}
r += cs.rowOffset
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r))
cs.pending.WriteByte(';')
cs.pending.WriteString(strconv.Itoa(c))
cs.pending.WriteByte(final)
case 'd':
// VPA: row.
r, ok := parseOneParam(paramsRaw, 1)
if !ok {
cs.pending.Write(cs.buf)
return
}
r += cs.rowOffset
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r))
cs.pending.WriteByte(final)
case 'r':
// DECSTBM: top;bot. Empty resets to full region; we still
// shift to keep the chrome row reserved.
top, bot, ok := parseTwoParams(paramsRaw)
if !ok {
cs.pending.Write(cs.buf)
return
}
top += cs.rowOffset
bot += cs.rowOffset
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(top))
cs.pending.WriteByte(';')
cs.pending.WriteString(strconv.Itoa(bot))
cs.pending.WriteByte(final)
default:
cs.pending.Write(cs.buf)
}
}
func isCSIFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
func parseTwoParams(raw []byte) (int, int, bool) {
parts := strings.Split(string(raw), ";")
if len(parts) > 2 {
return 0, 0, false
}
a := 1
b := 1
if len(parts) >= 1 && parts[0] != "" {
n, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
a = n
}
if len(parts) >= 2 && parts[1] != "" {
n, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
b = n
}
return a, b, true
}
func parseOneParam(raw []byte, def int) (int, bool) {
s := string(raw)
if s == "" {
return def, true
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
}

View File

@@ -0,0 +1,77 @@
package app
import (
"bytes"
"testing"
)
func TestCursorShifterCUP(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[H"))
want := []byte("\x1b[2;1H")
if !bytes.Equal(got, want) {
t.Fatalf("CUP home: got %q want %q", got, want)
}
}
func TestCursorShifterCUPRowCol(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[10;5H"))
if string(got) != "\x1b[11;5H" {
t.Fatalf("CUP 10;5: got %q", got)
}
}
func TestCursorShifterVPA(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[7d"))
if string(got) != "\x1b[8d" {
t.Fatalf("VPA 7: got %q", got)
}
}
func TestCursorShifterDECSTBM(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[2;20r"))
if string(got) != "\x1b[3;21r" {
t.Fatalf("DECSTBM: got %q", got)
}
}
func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
cs := newCursorShifter(1)
// Alt-screen toggle — private CSI.
got := cs.Shift([]byte("\x1b[?1049h"))
if string(got) != "\x1b[?1049h" {
t.Fatalf("alt-screen: got %q", got)
}
}
func TestCursorShifterSGRPassthrough(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
if string(got) != "\x1b[1;31mhello\x1b[0m" {
t.Fatalf("SGR: got %q", got)
}
}
func TestCursorShifterStraddleChunks(t *testing.T) {
cs := newCursorShifter(1)
a := cs.Shift([]byte("\x1b["))
b := cs.Shift([]byte("5;3H"))
got := string(a) + string(b)
if got != "\x1b[6;3H" {
t.Fatalf("straddle: got %q", got)
}
}
func TestCursorShifterOSCNotRewritten(t *testing.T) {
cs := newCursorShifter(1)
// OSC body containing what looks like a CSI cursor move — should
// NOT be rewritten.
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")
got := cs.Shift(in)
if string(got) != string(in) {
t.Fatalf("OSC: got %q want %q", got, in)
}
}

335
internal/app/host.go Normal file
View File

@@ -0,0 +1,335 @@
package app
import (
"fmt"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/policy"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad"
)
// attentionSink is implemented by uiState to surface
// request_human_attention notifications.
type attentionSink interface {
notifyAttention(childID, reason string)
}
// toolHost adapts the running session + scratchpad store to the MCP
// ToolHost interface. SPEC §7 tools route through here.
type toolHost struct {
sess *Session
pads *scratchpad.Store
launcher *Launcher
presets preset.Set
policy *policy.Policy
sizeMu sync.Mutex
defaultRow uint16
defaultCol uint16
attention attentionSink
// timersMu guards timers.
timersMu sync.Mutex
nextTimer int
}
func (h *toolHost) SetSize(cols, rows uint16) {
h.sizeMu.Lock()
defer h.sizeMu.Unlock()
h.defaultCol = cols
h.defaultRow = rows
}
func (h *toolHost) size() (uint16, uint16) {
h.sizeMu.Lock()
defer h.sizeMu.Unlock()
return h.defaultCol, h.defaultRow
}
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, pol *policy.Policy, cols, rows uint16) *toolHost {
return &toolHost{
sess: sess,
pads: pads,
launcher: launcher,
presets: presets,
policy: pol,
defaultCol: cols,
defaultRow: rows,
}
}
// PolicyCheck — SPEC §9 hook. Lets an orchestrator ask whether a
// prompt-looking string is safe to auto-answer.
func (h *toolHost) PolicyCheck(prompt string) string {
if h.policy == nil {
return string(policy.Unknown)
}
return string(h.policy.Should(prompt))
}
// Children — SPEC §7 list_children. The idle_ms field gives the
// orchestrator the SPEC §11 done-signal without needing to poll bytes.
func (h *toolHost) Children() []mcp.ChildInfo {
cs := h.sess.Children()
out := make([]mcp.ChildInfo, 0, len(cs))
for _, c := range cs {
out = append(out, mcp.ChildInfo{
ID: c.ID,
Name: c.Name,
Type: string(c.Kind),
Status: string(c.Status()),
ExitCode: c.ExitCode(),
IdleMS: c.IdleMS(),
ParentID: c.ParentID,
})
}
return out
}
func (h *toolHost) Spawn(callerID, name string, argv []string, shell bool) (mcp.ChildInfo, error) {
if shell && len(argv) > 0 {
argv = []string{"sh", "-lc", strings.Join(argv, " ")}
}
parent := callerID
cols, rows := h.size()
c, err := h.sess.Spawn(name, KindProcess, argv, nil, cols, rows, parent)
if err != nil {
return mcp.ChildInfo{}, err
}
return mcp.ChildInfo{
ID: c.ID,
Name: c.Name,
Type: string(c.Kind),
Status: string(c.Status()),
ParentID: c.ParentID,
}, nil
}
func (h *toolHost) SpawnAgent(callerID, presetName, displayName, initialPrompt string) (mcp.ChildInfo, error) {
var p *preset.Preset
for _, ap := range h.presets.Agents {
if ap.Name == presetName {
p = ap
break
}
}
if p == nil {
return mcp.ChildInfo{}, fmt.Errorf("unknown agent preset %q", presetName)
}
if displayName == "" {
displayName = presetName
}
c, err := h.launcher.LaunchAgent(p, displayName, initialPrompt, callerID)
if err != nil {
return mcp.ChildInfo{}, err
}
return mcp.ChildInfo{
ID: c.ID,
Name: c.Name,
Type: string(c.Kind),
Status: string(c.Status()),
ParentID: c.ParentID,
}, nil
}
// ReadOutput — SPEC §7. Grid uses the emulator's PlainText; stream uses
// the per-child ring buffer. For grid reads on agents we apply the
// preset's chrome_trim_hints (SPEC §10) so banners/input-box noise
// doesn't pollute orchestrator parsing.
func (h *toolHost) ReadOutput(callerID, childID, mode string, sinceOffset int) (string, int, error) {
c := h.sess.FindChild(childID)
if c == nil {
return "", 0, fmt.Errorf("no such child %q", childID)
}
switch mode {
case "grid":
txt, err := c.em.PlainText()
if err != nil {
return "", 0, err
}
if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.Name))
}
return txt, 0, nil
case "stream":
b, off := c.StreamRead(int64(sinceOffset))
return string(b), int(off), nil
default:
return "", 0, fmt.Errorf("unknown read_output mode %q", mode)
}
}
func (h *toolHost) chromeHintsFor(presetName string) []string {
for _, p := range h.presets.Agents {
if p.Name == presetName {
return p.ChromeTrimHints
}
}
return nil
}
// applyChromeTrim deletes every line that matches any of the given
// regexes. Compiled regexes are cached per call; the agent preset list
// is small enough that recompilation cost is negligible.
func applyChromeTrim(txt string, hints []string) string {
if len(hints) == 0 {
return txt
}
res := make([]*regexp.Regexp, 0, len(hints))
for _, h := range hints {
re, err := regexp.Compile(h)
if err != nil {
continue
}
res = append(res, re)
}
if len(res) == 0 {
return txt
}
out := make([]string, 0, 64)
for _, line := range strings.Split(txt, "\n") {
drop := false
for _, re := range res {
if re.MatchString(line) {
drop = true
break
}
}
if !drop {
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
func (h *toolHost) SendInput(callerID, childID string, payload []byte, appendNewline bool) error {
if appendNewline {
payload = append(payload, '\n')
}
c := h.sess.FindChild(childID)
if c == nil {
return fmt.Errorf("no such child %q", childID)
}
if c.Status() != StatusRunning {
return fmt.Errorf("child %q is %s", childID, c.Status())
}
return c.InjectAsOrchestrator(payload)
}
func (h *toolHost) Kill(callerID, childID string, sig syscall.Signal) error {
return h.sess.Kill(childID, sig)
}
// SendMessageTo — SPEC §7 + §8. Injects "[orchestrator] <msg>\n" into
// the target's PTY.
func (h *toolHost) SendMessageTo(callerID, targetID, message string) error {
target := h.sess.FindChild(targetID)
if target == nil {
return fmt.Errorf("no such child %q", targetID)
}
line := "[orchestrator] " + message + "\n"
return target.InjectAsOrchestrator([]byte(line))
}
// ReportToParent — SPEC §8. Injects "[sub-agent:<name>] <msg>\n" into
// the calling agent's parent pane.
func (h *toolHost) ReportToParent(callerID, message string) error {
caller := h.sess.FindChild(callerID)
if caller == nil {
return fmt.Errorf("caller %q not known to patterm", callerID)
}
if caller.ParentID == "" {
return fmt.Errorf("caller %q has no parent", callerID)
}
parent := h.sess.FindChild(caller.ParentID)
if parent == nil {
return fmt.Errorf("parent %q gone", caller.ParentID)
}
line := fmt.Sprintf("[sub-agent:%s] %s\n", caller.Name, message)
return parent.InjectAsOrchestrator([]byte(line))
}
// TimerWait — SPEC §7. Returns immediately with a timer_id. After
// seconds elapse, injects "[system] Your timer [<label>] has completed.\n"
// into the calling agent's pane.
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
caller := h.sess.FindChild(callerID)
if caller == nil {
return "", fmt.Errorf("caller %q not known to patterm", callerID)
}
h.timersMu.Lock()
h.nextTimer++
id := fmt.Sprintf("t%d", h.nextTimer)
h.timersMu.Unlock()
if label == "" {
label = id
}
go func() {
time.Sleep(time.Duration(seconds * float64(time.Second)))
if caller.Status() != StatusRunning {
return
}
line := fmt.Sprintf("[system] Your timer [%s] has completed.\n", label)
_ = caller.InjectAsOrchestrator([]byte(line))
}()
return id, nil
}
// WaitForPattern — SPEC §7. Polls the child's plain-text grid at ~50ms
// until the regex matches or the timeout expires.
func (h *toolHost) WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (bool, string, error) {
c := h.sess.FindChild(childID)
if c == nil {
return false, "", fmt.Errorf("no such child %q", childID)
}
re, err := regexp.Compile(pattern)
if err != nil {
return false, "", fmt.Errorf("regex: %w", err)
}
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
for {
txt, err := c.em.PlainText()
if err == nil {
if m := re.FindString(txt); m != "" {
return true, m, nil
}
}
if time.Now().After(deadline) {
return false, "", nil
}
<-tick.C
if c.Status() != StatusRunning {
return false, "", nil
}
}
}
func (h *toolHost) RequestHumanAttention(callerID, childID, reason string) error {
if h.attention != nil {
h.attention.notifyAttention(childID, reason)
}
return nil
}
func (h *toolHost) Scratchpads() *scratchpad.Store {
return h.pads
}
// ResolveCallerIdentity maps the per-spawn identity token back to a
// child ID so the tools above can use it as a parent pointer / inject
// target.
func (h *toolHost) ResolveCallerIdentity(identity string) string {
c := h.sess.FindChildByIdentity(identity)
if c == nil {
return ""
}
return c.ID
}

179
internal/app/launch.go Normal file
View File

@@ -0,0 +1,179 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/harrybrwn/patterm/internal/preset"
)
// Launcher knows how to turn a preset into a running child. Both the
// palette and the MCP spawn_agent tool route through here so MCP
// injection happens consistently. SPEC §10.
type Launcher struct {
sess *Session
mcpSocket string
bin string // path to this binary (for the mcp-stdio subcommand)
sizeMu sync.Mutex
cols, rows uint16
}
func NewLauncher(sess *Session, mcpSocket string, cols, rows uint16) *Launcher {
bin, err := os.Executable()
if err != nil {
bin = "patterm"
}
return &Launcher{sess: sess, mcpSocket: mcpSocket, bin: bin, cols: cols, rows: rows}
}
func (l *Launcher) SetSize(cols, rows uint16) {
l.sizeMu.Lock()
defer l.sizeMu.Unlock()
l.cols, l.rows = cols, rows
}
func (l *Launcher) size() (uint16, uint16) {
l.sizeMu.Lock()
defer l.sizeMu.Unlock()
return l.cols, l.rows
}
// LaunchAgent spawns the agent preset, applies the preset's MCP
// injection, waits for the ready signal, and types initial_prompt into
// the PTY. SPEC §7 spawn_agent, §8 conversation protocol.
func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, parentID string) (*Child, error) {
if p.Kind != preset.KindAgent {
return nil, fmt.Errorf("launch: %q is not an agent preset", p.Name)
}
argv := append([]string(nil), p.Argv...)
env := l.sess.ChildEnv()
for k, v := range p.Env {
env = append(env, k+"="+v)
}
// Mint a per-spawn MCP config file pointing at the mcp-stdio proxy
// with the new child's identity. We don't know the identity until
// we've created the child, but the child needs the env/argv at
// creation time — so we reserve the identity by pre-creating the
// MCP config with a placeholder, then patching it post-spawn.
identity, mcpConfigPath, err := l.writeMCPConfig()
if err != nil {
return nil, err
}
if p.MCPInjection != nil {
switch p.MCPInjection.Kind {
case "flag":
if p.MCPInjection.Flag == "" {
return nil, fmt.Errorf("preset %s: mcp_injection.flag required for kind=flag", p.Name)
}
argv = append(argv, p.MCPInjection.Flag, mcpConfigPath)
case "env_var":
if p.MCPInjection.Var == "" {
return nil, fmt.Errorf("preset %s: mcp_injection.var required for kind=env_var", p.Name)
}
env = append(env, p.MCPInjection.Var+"="+mcpConfigPath)
case "config_file":
// SPEC §10 mentions merging into an external config file. We
// expose the config_path via an env var the user can read
// at preset-creation time; full merge is deferred.
env = append(env, "PATTERM_MCP_CONFIG="+mcpConfigPath)
default:
return nil, fmt.Errorf("preset %s: unknown mcp_injection.kind %q", p.Name, p.MCPInjection.Kind)
}
}
// Spawn with the chosen identity.
cols, rows := l.size()
c, err := l.sess.spawnWithIdentity(displayName, KindAgent, argv, env, cols, rows, parentID, identity)
if err != nil {
_ = os.Remove(mcpConfigPath)
return nil, err
}
// Wait for the preset's ready signal, then type the initial prompt.
idle := time.Duration(1000) * time.Millisecond
if p.ReadySignal != nil && p.ReadySignal.IdleMS > 0 {
idle = time.Duration(p.ReadySignal.IdleMS) * time.Millisecond
}
go func() {
waitForIdle(c, idle, 30*time.Second)
if initialPrompt == "" {
return
}
_ = c.InjectAsOrchestrator([]byte(initialPrompt + "\n"))
}()
return c, nil
}
// LaunchProcess spawns a process preset. No MCP injection; just argv.
func (l *Launcher) LaunchProcess(p *preset.Preset, displayName string) (*Child, error) {
if p.Kind != preset.KindProcess {
return nil, fmt.Errorf("launch: %q is not a process preset", p.Name)
}
env := l.sess.ChildEnv()
for k, v := range p.Env {
env = append(env, k+"="+v)
}
cols, rows := l.size()
return l.sess.Spawn(displayName, KindProcess, p.ResolvedArgv(), env, cols, rows, "")
}
func (l *Launcher) writeMCPConfig() (identity, path string, err error) {
identity = mintIdentity()
dir, err := preset.ConfigDir()
if err != nil {
return "", "", err
}
dir = filepath.Join(dir, "mcp")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", err
}
path = filepath.Join(dir, identity+".json")
cfg := map[string]any{
"mcpServers": map[string]any{
"patterm": map[string]any{
"command": l.bin,
"args": []string{"mcp-stdio", "--socket", l.mcpSocket, "--identity", identity},
},
},
}
body, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", "", err
}
body = append(body, '\n')
if err := os.WriteFile(path, body, 0o600); err != nil {
return "", "", err
}
return identity, path, nil
}
// waitForIdle polls the child's IdleMS until it exceeds idle, or until
// max elapses.
func waitForIdle(c *Child, idle, max time.Duration) {
deadline := time.Now().Add(max)
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
<-tick.C
if c.Status() != StatusRunning {
return
}
if c.IdleMS() >= idle.Milliseconds() && c.lastWriteNS.Load() != 0 {
return
}
if time.Now().After(deadline) {
return
}
}
}
// joinArgs flattens an argv slice into a single line (used for display
// hints).
func joinArgs(argv []string) string { return strings.Join(argv, " ") }

73
internal/app/layout.go Normal file
View File

@@ -0,0 +1,73 @@
package app
// terminalLayout is the single source of truth for host chrome and the
// child PTY viewport.
type terminalLayout struct {
hostCols uint16
hostRows uint16
mainLeft uint16
mainTop uint16
mainCols uint16
mainRows uint16
sidebarVisible bool
sidebarLeft uint16
sidebarWidth uint16
statusRow uint16
}
func newTerminalLayout(cols, rows uint16) terminalLayout {
if cols == 0 {
cols = 1
}
if rows == 0 {
rows = 1
}
l := terminalLayout{
hostCols: cols,
hostRows: rows,
mainLeft: 1,
mainTop: tabBarRows + 1,
mainCols: cols,
mainRows: 1,
statusRow: rows,
}
if int(cols) > sidebarCols+10 {
l.sidebarVisible = true
l.sidebarWidth = sidebarCols
l.sidebarLeft = cols - sidebarCols + 1
l.mainCols = cols - sidebarCols
}
reservedRows := tabBarRows + statusRows
if int(rows) > reservedRows {
l.mainRows = rows - uint16(reservedRows)
}
return l
}
func (l terminalLayout) childCols() uint16 {
if l.mainCols == 0 {
return 1
}
return l.mainCols
}
func (l terminalLayout) childRows() uint16 {
if l.mainRows == 0 {
return 1
}
return l.mainRows
}
func ptyRows(hostRows uint16) uint16 {
return newTerminalLayout(1, hostRows).childRows()
}
func ptyCols(hostCols uint16) uint16 {
return newTerminalLayout(hostCols, 1).childCols()
}

View File

@@ -0,0 +1,58 @@
package app
import (
"testing"
"github.com/harrybrwn/patterm/internal/preset"
)
func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
l := newTerminalLayout(120, 40)
if !l.sidebarVisible {
t.Fatal("wide layout should show sidebar")
}
if l.childCols() != 92 {
t.Fatalf("child cols: got %d want 92", l.childCols())
}
if l.childRows() != 38 {
t.Fatalf("child rows: got %d want 38", l.childRows())
}
if l.mainTop != 2 || l.statusRow != 40 {
t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow)
}
}
func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) {
l := newTerminalLayout(38, 12)
if l.sidebarVisible {
t.Fatal("narrow layout should hide sidebar")
}
if l.childCols() != 38 {
t.Fatalf("child cols: got %d want 38", l.childCols())
}
if l.childRows() != 10 {
t.Fatalf("child rows: got %d want 10", l.childRows())
}
}
func TestTerminalLayoutTinyClampsChildSize(t *testing.T) {
l := newTerminalLayout(0, 1)
if l.childCols() != 1 || l.childRows() != 1 {
t.Fatalf("child size: got %dx%d want 1x1", l.childCols(), l.childRows())
}
}
func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
l := newTerminalLayout(120, 40)
launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
cols, rows := launcher.size()
if cols != 92 || rows != 38 {
t.Fatalf("launcher size: got %dx%d want 92x38", cols, rows)
}
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
cols, rows = host.size()
if cols != 92 || rows != 38 {
t.Fatalf("tool host size: got %dx%d want 92x38", cols, rows)
}
}

332
internal/app/palette.go Normal file
View File

@@ -0,0 +1,332 @@
package app
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/harrybrwn/patterm/internal/preset"
)
// paletteAction is what the palette returns when the user picks an item.
type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "switch" | "kill" | "quit" | "cancel"
kind string
// For spawn-*, the preset to launch.
preset *preset.Preset
// For "switch" and "kill", the target child id.
childID string
}
type paletteItem struct {
label string
hint string
action paletteAction
}
// paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct {
query []rune
cursor int
children []*Child
focused string
presets preset.Set
items []paletteItem
}
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, presets: presets}
p.rebuild()
return p
}
func (p *paletteState) rebuild() {
all := p.allItems()
q := strings.ToLower(string(p.query))
if q == "" {
p.items = all
} else {
p.items = p.items[:0]
for _, it := range all {
if fuzzyMatch(strings.ToLower(it.label+" "+it.hint), q) {
p.items = append(p.items, it)
}
}
}
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
if p.cursor < 0 {
p.cursor = 0
}
}
func (p *paletteState) allItems() []paletteItem {
var out []paletteItem
// Preset commands first — SPEC §4 calls these out as the primary
// way to spawn anything. One entry per file under presets/.
for _, pr := range p.presets.Agents {
out = append(out, paletteItem{
label: "Spawn agent: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-agent", preset: pr},
})
}
for _, pr := range p.presets.Processes {
out = append(out, paletteItem{
label: "Run process: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-process", preset: pr},
})
}
// Switch / Kill entries — one per existing child.
for _, c := range p.children {
label := "Switch to " + c.Name
hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "• " + label + " (current)"
}
if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]"
}
out = append(out, paletteItem{
label: label,
hint: hint,
action: paletteAction{kind: "switch", childID: c.ID},
})
}
for _, c := range p.children {
if c.Status() != StatusRunning {
continue
}
out = append(out, paletteItem{
label: "Kill " + c.Name,
hint: "SIGTERM " + strings.Join(c.Argv, " "),
action: paletteAction{kind: "kill", childID: c.ID},
})
}
out = append(out, paletteItem{
label: "Quit",
hint: "exit patterm; SIGTERM every child",
action: paletteAction{kind: "quit"},
})
return out
}
func fuzzyMatch(hay, needle string) bool {
if needle == "" {
return true
}
hi := 0
for _, r := range needle {
idx := strings.IndexRune(hay[hi:], r)
if idx < 0 {
return false
}
hi += idx + utf8.RuneLen(r)
}
return true
}
func (p *paletteState) handleKey(b byte, peek []byte) (paletteAction, bool) {
if b == 0x1b {
// Pure Esc cancels; Esc [ A/B is up/down arrow.
if len(peek) >= 2 && peek[0] == '[' {
switch peek[1] {
case 'A':
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
return paletteAction{}, false
case 'B':
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
return paletteAction{}, false
}
}
return paletteAction{kind: "cancel"}, true
}
switch b {
case '\r', '\n':
if p.cursor >= 0 && p.cursor < len(p.items) {
return p.items[p.cursor].action, true
}
return paletteAction{kind: "cancel"}, true
case 0x7f, 0x08:
if len(p.query) > 0 {
p.query = p.query[:len(p.query)-1]
p.rebuild()
}
case 0x15: // Ctrl-U
p.query = p.query[:0]
p.rebuild()
case 0x0e: // Ctrl-N
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
case 0x10: // Ctrl-P inside palette: cursor up.
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
case 0x16: // Ctrl-V literal-paste — ignore in palette.
default:
if b >= 0x20 && b < 0x7f {
p.query = append(p.query, rune(b))
p.rebuild()
}
}
return paletteAction{}, false
}
// render draws the palette onto out. Geometry: title bar + filter line +
// items + footer, centred. The caller is responsible for the screen
// clear before the first render.
func (p *paletteState) render(out writeFlusher, cols, rows int) {
if cols < 20 {
cols = 20
}
if rows < 6 {
rows = 6
}
width := cols - 4
if width > 80 {
width = 80
}
if width < 40 {
width = cols - 2
}
leftPad := (cols - width) / 2
if leftPad < 1 {
leftPad = 1
}
row := 2
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
moveTo(&b, row, leftPad)
b.WriteString("\x1b[1;7m")
b.WriteString(padRight(" patterm — Ctrl-K", width))
b.WriteString("\x1b[0m")
row++
moveTo(&b, row, leftPad)
b.WriteString("\x1b[7m")
b.WriteString(padRight(" "+string(p.query)+"_", width))
b.WriteString("\x1b[0m")
row++
maxItems := rows - 6
if maxItems > 12 {
maxItems = 12
}
if maxItems < 1 {
maxItems = 1
}
start := 0
if p.cursor >= maxItems {
start = p.cursor - maxItems + 1
}
end := start + maxItems
if end > len(p.items) {
end = len(p.items)
}
for i := start; i < end; i++ {
it := p.items[i]
moveTo(&b, row, leftPad)
if i == p.cursor {
b.WriteString("\x1b[7m")
} else {
b.WriteString("\x1b[0m")
}
line := " " + it.label
if it.hint != "" {
line += " \x1b[2m— " + it.hint + "\x1b[0m"
if i == p.cursor {
line += "\x1b[7m"
}
}
b.WriteString(padRight(line, width+countAnsi(line)))
b.WriteString("\x1b[0m")
row++
}
if len(p.items) == 0 {
moveTo(&b, row, leftPad)
b.WriteString("\x1b[2m no matches\x1b[0m")
row++
}
moveTo(&b, row, leftPad)
b.WriteString("\x1b[2m")
b.WriteString(padRight(" Enter to run · Esc to close · ↑↓ to navigate", width))
b.WriteString("\x1b[0m")
moveTo(&b, 3, leftPad+4+utf8.RuneCountInString(string(p.query)))
b.WriteString("\x1b[?25h")
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
type writeFlusher interface {
Write(p []byte) (int, error)
Flush() error
}
type writeFlusherBase interface {
Write(p []byte) (int, error)
}
type nopFlusher struct{ io writeFlusherBase }
func wrapWriter(w writeFlusherBase) writeFlusher { return nopFlusher{io: w} }
func (n nopFlusher) Write(p []byte) (int, error) { return n.io.Write(p) }
func (n nopFlusher) Flush() error { return nil }
func moveTo(b *strings.Builder, row, col int) {
fmt.Fprintf(b, "\x1b[%d;%dH", row, col)
}
func padRight(s string, width int) string {
w := width - visibleLen(s)
if w <= 0 {
return s
}
return s + strings.Repeat(" ", w)
}
func visibleLen(s string) int {
n := 0
in := false
for _, r := range s {
if r == 0x1b {
in = true
continue
}
if in {
if r == 'm' || r == 'H' {
in = false
}
continue
}
n++
}
return n
}
func countAnsi(s string) int {
return len(s) - visibleLen(s)
}

View File

@@ -0,0 +1,86 @@
package app
import (
"fmt"
"strings"
"github.com/harrybrwn/patterm/internal/vt"
)
func renderScreenSnapshot(text string, cursor vt.CursorState, layout terminalLayout) []byte {
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
cols := int(layout.childCols())
rows := int(layout.childRows())
var b strings.Builder
b.WriteString("\x1b[?25l")
for r := 0; r < rows; r++ {
line := ""
if r < len(lines) {
line = truncateCells(lines[r], cols)
}
fmt.Fprintf(&b, "\x1b[%d;%dH%s", int(layout.mainTop)+r, int(layout.mainLeft), padRight(line, cols))
}
if cursor.Visible {
row := int(layout.mainTop) + int(cursor.Row)
col := int(layout.mainLeft) + int(cursor.Col)
if row < int(layout.mainTop) {
row = int(layout.mainTop)
}
maxRow := int(layout.mainTop) + rows - 1
if row > maxRow {
row = maxRow
}
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
maxCol := int(layout.mainLeft) + cols - 1
if col > maxCol {
col = maxCol
}
fmt.Fprintf(&b, "\x1b[?25h\x1b[%d;%dH", row, col)
}
return []byte(b.String())
}
func renderCursor(cursor vt.CursorState, layout terminalLayout) []byte {
cols := int(layout.childCols())
rows := int(layout.childRows())
row := int(layout.mainTop) + int(cursor.Row)
col := int(layout.mainLeft) + int(cursor.Col)
if row < int(layout.mainTop) {
row = int(layout.mainTop)
}
maxRow := int(layout.mainTop) + rows - 1
if row > maxRow {
row = maxRow
}
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
maxCol := int(layout.mainLeft) + cols - 1
if col > maxCol {
col = maxCol
}
return []byte(fmt.Sprintf("\x1b[?25h\x1b[%d;%dH", row, col))
}
func truncateCells(s string, width int) string {
if width <= 0 {
return ""
}
if visibleLen(s) <= width {
return s
}
var b strings.Builder
n := 0
for _, r := range s {
if n >= width {
break
}
b.WriteRune(r)
n++
}
return b.String()
}

View File

@@ -0,0 +1,33 @@
package app
import (
"strings"
"testing"
"github.com/harrybrwn/patterm/internal/vt"
)
func TestRenderScreenSnapshotClipsRowsToViewport(t *testing.T) {
layout := newTerminalLayout(20, 5)
got := string(renderScreenSnapshot("abcdefghijklmnopqrstuvwxy\nsecond", vt.CursorState{}, layout))
if strings.Contains(got, "uvwxy") {
t.Fatalf("line leaked past viewport width: %q", got)
}
if !strings.Contains(got, "\x1b[2;1Habcdefghijklmnopqrst") {
t.Fatalf("first row not drawn at viewport top: %q", got)
}
if !strings.Contains(got, "\x1b[3;1Hsecond ") {
t.Fatalf("second row not padded in viewport: %q", got)
}
if !strings.Contains(got, "\x1b[4;1H ") {
t.Fatalf("blank viewport row not cleared: %q", got)
}
}
func TestRenderScreenSnapshotPlacesCursorInsideViewport(t *testing.T) {
layout := newTerminalLayout(20, 5)
got := string(renderScreenSnapshot("abc", vt.CursorState{Col: 2, Row: 1, Visible: true}, layout))
if !strings.HasSuffix(got, "\x1b[?25h\x1b[3;3H") {
t.Fatalf("cursor not placed inside viewport: %q", got)
}
}

315
internal/app/session.go Normal file
View File

@@ -0,0 +1,315 @@
// Package app is patterm's single foreground process. It owns the TUI,
// every PTY, every emulator, the in-process MCP server, and the
// scratchpad/preset state.
//
// There is no daemon, no detach, no socket-based client/daemon split
// (SPEC §2). One process owns everything; closing the terminal window
// ends the session and tears down every child.
package app
import (
"errors"
"fmt"
"os"
"sync"
"sync/atomic"
"syscall"
"github.com/harrybrwn/patterm/internal/vt"
)
// Session is the in-memory state for the running patterm process.
// In SPEC §4 terms each top-level tab is a session; v1 ships with a
// single implicit session and reserves room to grow.
type Session struct {
projectDir string
projectKey string
mu sync.Mutex
children map[string]*Child
order []string
nextChildSeq atomic.Int64
// listeners is the set of UI listeners that want to hear about child
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
listenersMu sync.Mutex
listeners []ChildEventListener
}
// ChildEventListener is implemented by the TUI to react to lifecycle
// events without polling.
type ChildEventListener interface {
OnChildSpawned(*Child)
OnChildExited(*Child)
// OnPTYOut is called for every chunk the child writes to its PTY.
// Only the focused-child chunk should reach the screen — the TUI
// filters by id.
OnPTYOut(childID string, chunk []byte)
}
func NewSession(projectDir, projectKey string) *Session {
return &Session{
projectDir: projectDir,
projectKey: projectKey,
children: make(map[string]*Child),
}
}
func (s *Session) Subscribe(l ChildEventListener) {
s.listenersMu.Lock()
defer s.listenersMu.Unlock()
s.listeners = append(s.listeners, l)
}
func (s *Session) emitSpawn(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnChildSpawned(c)
}
}
func (s *Session) emitExit(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnChildExited(c)
}
}
func (s *Session) emitPTYOut(id string, chunk []byte) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnPTYOut(id, chunk)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
// detect it and degrade. The MCP socket is per-PID and lives under
// $XDG_RUNTIME_DIR — see internal/mcp.
env = append(env,
"PATTERM=1",
"PATTERM_PROJECT_KEY="+s.projectKey,
"PATTERM_PROJECT_DIR="+s.projectDir,
)
return env
}
// Spawn launches a new child with the given argv. kind is "agent" or
// "process". parentID names the calling session/child for orchestrator
// trees ("" for top-level). env is the full child environment; the
// caller is responsible for adding preset-specific overrides.
func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
s.mu.Lock()
id := fmt.Sprintf("c%d", s.nextChildSeq.Add(1))
if name == "" {
name = fmt.Sprintf("%s-%s", kind, id)
}
s.mu.Unlock()
if env == nil {
env = s.ChildEnv()
}
c, err := newChild(id, name, kind, argv, env, cols, rows, parentID)
if err != nil {
return nil, err
}
s.mu.Lock()
s.children[id] = c
s.order = append(s.order, id)
s.mu.Unlock()
s.emitSpawn(c)
go s.pumpChild(c)
go s.reapChild(c)
return c, nil
}
// spawnWithIdentity is like Spawn but lets the launcher pre-mint the
// MCP identity so the config file can be written before the process
// starts.
func (s *Session) spawnWithIdentity(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, identity string) (*Child, error) {
c, err := s.Spawn(name, kind, argv, env, cols, rows, parentID)
if err != nil {
return nil, err
}
c.Identity = identity
return c, nil
}
func (s *Session) pumpChild(c *Child) {
buf := make([]byte, 64*1024)
for {
n, err := c.pty.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
if _, werr := c.em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr)
}
c.recordWrite(chunk)
s.emitPTYOut(c.ID, chunk)
}
if err != nil {
if !errors.Is(err, syscall.EIO) && !errors.Is(err, os.ErrClosed) {
logf("pty read (child %s): %v", c.ID, err)
}
return
}
}
}
func (s *Session) reapChild(c *Child) {
err := c.pty.Wait()
c.markExited(err)
logf("child %s exited (err=%v)", c.ID, err)
s.emitExit(c)
}
// Children returns a snapshot of children in spawn order.
func (s *Session) Children() []*Child {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*Child, 0, len(s.order))
for _, id := range s.order {
if c, ok := s.children[id]; ok {
out = append(out, c)
}
}
return out
}
// FindChild looks up a child by id; returns nil if not present.
func (s *Session) FindChild(id string) *Child {
s.mu.Lock()
defer s.mu.Unlock()
return s.children[id]
}
// FindChildByIdentity finds the child whose Identity matches token.
// Used by MCP to bind a mcp-stdio greeting to its caller. Returns nil
// if no match.
func (s *Session) FindChildByIdentity(token string) *Child {
if token == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.children {
if c.Identity == token {
return c
}
}
return nil
}
// Kill sends a signal (default SIGTERM) to a child by id.
func (s *Session) Kill(id string, sig syscall.Signal) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such child %q", id)
}
if sig == 0 {
sig = syscall.SIGTERM
}
return c.signal(sig)
}
// WriteInput pipes bytes to a child's PTY stdin.
func (s *Session) WriteInput(id string, b []byte) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such child %q", id)
}
if c.Status() != StatusRunning {
return fmt.Errorf("child %q is %s", id, c.Status())
}
_, err := c.pty.Write(b)
return err
}
// ResizeAll updates every child's PTY + emulator to the same cell grid.
// SPEC §5 says one viewport, no multi-client resize negotiation.
func (s *Session) ResizeAll(cols, rows uint16) {
if cols == 0 || rows == 0 {
return
}
s.mu.Lock()
cs := make([]*Child, 0, len(s.children))
for _, c := range s.children {
cs = append(cs, c)
}
s.mu.Unlock()
for _, c := range cs {
_ = c.pty.Resize(cols, rows)
_ = c.em.Resize(cols, rows)
}
}
// SerializeChild returns the VT bytes that reproduce the child's
// current screen state. Used to repaint a child after the user switches
// focus or closes the palette.
func (s *Session) SerializeChild(id string) ([]byte, error) {
c := s.FindChild(id)
if c == nil {
return nil, fmt.Errorf("no such child %q", id)
}
return c.em.SerializeVT()
}
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
c := s.FindChild(id)
if c == nil {
return "", vt.CursorState{}, fmt.Errorf("no such child %q", id)
}
text, err := c.em.ScreenText()
if err != nil {
return "", vt.CursorState{}, err
}
cursor, err := c.em.Cursor()
if err != nil {
return "", vt.CursorState{}, err
}
return text, cursor, nil
}
// Shutdown kills every child and waits briefly for them to drain.
// Called on Ctrl-D / SIGTERM / SIGHUP. SPEC §2 step 4.
func (s *Session) Shutdown() {
s.mu.Lock()
cs := make([]*Child, 0, len(s.children))
for _, c := range s.children {
cs = append(cs, c)
}
s.mu.Unlock()
for _, c := range cs {
_ = c.signal(syscall.SIGTERM)
}
// Close emulators and PTY masters. The reaper goroutines will fire
// emitExit as Wait() returns.
for _, c := range cs {
_ = c.pty.Close()
_ = c.em.Close()
}
}
func logf(format string, args ...any) {
if os.Getenv("PATTERM_DEBUG_LOG") == "" {
return
}
f, err := os.OpenFile(os.Getenv("PATTERM_DEBUG_LOG"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "patterm: "+format+"\n", args...)
}

143
internal/app/sidebar.go Normal file
View File

@@ -0,0 +1,143 @@
package app
import (
"fmt"
"os"
"strings"
)
const (
sidebarCols = 28
statusRows = 1
)
// drawSidebar paints the right-rail session tree + scratchpad list.
// SPEC §4: the rail is the active session's child hierarchy on top and
// the scratchpad list (with preview) on the bottom.
//
// Implementation note: the PTY child's winsize is constrained to the
// computed main viewport, so the sidebar region is outside the child's
// cursor range. We can redraw freely without fighting the child for cells.
func (st *uiState) drawSidebar() {
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
st.mu.Unlock()
if palOpen {
return
}
layout := st.layoutSnapshot()
if !layout.sidebarVisible || layout.hostRows < 4 {
return
}
left := int(layout.sidebarLeft)
width := int(layout.sidebarWidth) - 1
maxRow := int(layout.statusRow) - statusRows
var b strings.Builder
// Border column at left-1: a single vertical pipe.
for r := 1; r <= maxRow; r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[2m│\x1b[0m", r, left-1)
}
row := 1
writeLine := func(s string, style string) {
if row > maxRow {
return
}
if len(s) > width {
s = s[:width]
}
fmt.Fprintf(&b, "\x1b[%d;%dH%s%s\x1b[0m\x1b[K", row, left, style, padRight(s, width))
row++
}
writeLine(" Session tree", "\x1b[1m")
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
children := visibleSessionTree(st.sess.Children(), focus)
if len(children) == 0 {
writeLine(" (empty)", "\x1b[2m")
}
for _, c := range children {
glyph := "◉"
marker := " "
if c.ID == focus {
marker = "▶ "
}
indent := ""
if c.ParentID != "" {
indent = " "
}
line := fmt.Sprintf(" %s%s%s %s", marker, indent, glyph, c.Name)
style := ""
if c.ID == focus {
style = "\x1b[1m"
}
writeLine(line, style)
}
// Scratchpads list — pick the most-recently-modified one as the
// preview target. SPEC §4.
var previewName string
if row+2 <= maxRow {
row++
writeLine(" Scratchpads", "\x1b[1m")
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
entries, err := st.pads.List()
if err == nil {
if len(entries) == 0 {
writeLine(" (none)", "\x1b[2m")
}
var newest string
var newestTS string
for _, e := range entries {
if e.ModifiedAt > newestTS {
newestTS = e.ModifiedAt
newest = e.Name
}
}
previewName = newest
for _, e := range entries {
if row > maxRow {
break
}
marker := " "
style := ""
if e.Name == previewName {
marker = " ▸ "
style = "\x1b[1m"
}
writeLine(marker+e.Name, style)
}
}
}
// Preview pane at the bottom of the rail. Reserve up to 8 rows.
if previewName != "" && row+2 <= maxRow {
row++
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
writeLine(" "+previewName, "\x1b[1m")
content, _, err := st.pads.Read(previewName)
if err == nil {
for _, line := range strings.Split(content, "\n") {
if row > maxRow {
break
}
writeLine(" "+line, "\x1b[2m")
}
}
}
// Blank-fill any rows the rail content didn't cover so stale
// content from a previous redraw doesn't linger.
for row <= maxRow {
writeLine("", "")
}
st.outMu.Lock()
// Save cursor; emit the sidebar; restore.
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
st.outMu.Unlock()
}

70
internal/app/tabbar.go Normal file
View File

@@ -0,0 +1,70 @@
package app
import (
"fmt"
"os"
"strings"
)
const tabBarRows = 1
// drawTabBar renders SPEC §4's top tab bar at row 1. Tabs are top-level
// children (ParentID == ""); the focused tab is highlighted. The PTY
// region begins at row 2.
func (st *uiState) drawTabBar() {
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
st.mu.Unlock()
if palOpen {
return
}
layout := st.layoutSnapshot()
width := int(layout.childCols())
var sessions []*Child
for _, c := range st.sess.Children() {
if c.ParentID == "" && c.Status() == StatusRunning {
sessions = append(sessions, c)
}
}
var b strings.Builder
b.WriteString("\x1b[1;1H")
cur := 0
for _, c := range sessions {
label := c.Name
seg := " " + label + " "
if cur+len(seg) > width-2 {
break
}
if c.ID == focus {
b.WriteString("\x1b[7m")
} else {
b.WriteString("\x1b[2m")
}
b.WriteString(seg)
b.WriteString("\x1b[0m")
cur += len(seg)
}
// "+" hint at end.
hint := "+"
if cur > 0 {
hint = " +"
}
if cur+len(hint) <= width {
b.WriteString("\x1b[2m")
b.WriteString(hint)
b.WriteString("\x1b[0m")
cur += len(hint)
}
// Fill the rest of the tab-bar row so stale chars don't linger.
if width-cur > 0 {
b.WriteString(strings.Repeat(" ", width-cur))
}
st.outMu.Lock()
defer st.outMu.Unlock()
// Save cursor, paint, restore.
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
}

59
internal/app/tree.go Normal file
View File

@@ -0,0 +1,59 @@
package app
func visibleSessionTree(children []*Child, focusID string) []*Child {
rootID := activeRootID(children, focusID)
if rootID == "" {
return nil
}
out := make([]*Child, 0, len(children))
for _, c := range children {
if c.Status() != StatusRunning {
continue
}
if c.ID == rootID || c.ParentID == rootID {
out = append(out, c)
}
}
return out
}
func activeRootID(children []*Child, focusID string) string {
if focusID != "" {
for _, c := range children {
if c.ID != focusID {
continue
}
if c.ParentID == "" {
return c.ID
}
if parent := findChildInSnapshot(children, c.ParentID); parent != nil {
return parent.ID
}
return ""
}
}
for _, c := range children {
if c.ParentID == "" && c.Status() == StatusRunning {
return c.ID
}
}
return ""
}
func findChildInSnapshot(children []*Child, id string) *Child {
for _, c := range children {
if c.ID == id {
return c
}
}
return nil
}
func firstRunningTopLevel(children []*Child) *Child {
for _, c := range children {
if c.ParentID == "" && c.Status() == StatusRunning {
return c
}
}
return nil
}

41
internal/app/tree_test.go Normal file
View File

@@ -0,0 +1,41 @@
package app
import "testing"
func TestVisibleSessionTreeScopesToFocusedRoot(t *testing.T) {
root1 := testChild("c1", "root1", "", StatusRunning)
child1 := testChild("c2", "child1", "c1", StatusRunning)
root2 := testChild("c3", "root2", "", StatusRunning)
child2 := testChild("c4", "child2", "c3", StatusRunning)
got := visibleSessionTree([]*Child{root1, child1, root2, child2}, "c4")
if len(got) != 2 || got[0].ID != "c3" || got[1].ID != "c4" {
t.Fatalf("visible tree = %v, want root2 + child2", childIDs(got))
}
}
func TestVisibleSessionTreeOmitsExited(t *testing.T) {
root := testChild("c1", "root", "", StatusRunning)
exitedRoot := testChild("c2", "dead-root", "", StatusExited)
runningChild := testChild("c3", "child", "c1", StatusRunning)
exitedChild := testChild("c4", "dead-child", "c1", StatusExited)
got := visibleSessionTree([]*Child{root, exitedRoot, runningChild, exitedChild}, "c1")
if len(got) != 2 || got[0].ID != "c1" || got[1].ID != "c3" {
t.Fatalf("visible tree = %v, want running root + running child", childIDs(got))
}
}
func testChild(id, name, parent string, status ChildStatus) *Child {
c := &Child{ID: id, Name: name, ParentID: parent}
c.status.Store(&status)
return c
}
func childIDs(cs []*Child) []string {
ids := make([]string, 0, len(cs))
for _, c := range cs {
ids = append(ids, c.ID)
}
return ids
}

View File

@@ -0,0 +1,294 @@
package app
import (
"fmt"
"strconv"
"strings"
"sync"
)
// viewportRenderer rewrites child PTY output so it lands inside the
// main viewport instead of controlling patterm's full host terminal.
type viewportRenderer struct {
mu sync.Mutex
shifter *cursorShifter
layout terminalLayout
row int
col int
state viewportState
buf []byte
pending strings.Builder
}
type viewportState int
const (
vpNormal viewportState = iota
vpEsc
vpCSI
vpOSC
vpOSCEsc
vpDCS
vpDCSEsc
vpSOSPMAPC
vpSOSPMAPCEsc
)
func newViewportRenderer(l terminalLayout) *viewportRenderer {
return &viewportRenderer{
shifter: newCursorShifter(int(l.mainTop) - 1),
layout: l,
row: 1,
col: 1,
}
}
func (vr *viewportRenderer) SetLayout(l terminalLayout) {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.layout = l
vr.shifter.SetRowOffset(int(l.mainTop) - 1)
}
func (vr *viewportRenderer) Render(in []byte) []byte {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.pending.Reset()
for _, b := range in {
vr.feed(b)
}
return []byte(vr.pending.String())
}
func (vr *viewportRenderer) feed(b byte) {
switch vr.state {
case vpNormal:
if b == 0x1b {
vr.state = vpEsc
vr.buf = vr.buf[:0]
vr.buf = append(vr.buf, b)
return
}
vr.pending.WriteByte(b)
vr.advancePrintable(b)
case vpEsc:
vr.buf = append(vr.buf, b)
switch b {
case '[':
vr.state = vpCSI
case ']':
vr.state = vpOSC
case 'P':
vr.state = vpDCS
case 'X', '^', '_':
vr.state = vpSOSPMAPC
default:
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
}
case vpCSI:
vr.buf = append(vr.buf, b)
if isCSIFinal(b) {
vr.emitCSI()
vr.state = vpNormal
vr.buf = vr.buf[:0]
}
case vpOSC:
vr.buf = append(vr.buf, b)
switch b {
case 0x07:
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
case 0x1b:
vr.state = vpOSCEsc
}
case vpOSCEsc:
vr.buf = append(vr.buf, b)
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
case vpDCS:
vr.buf = append(vr.buf, b)
if b == 0x1b {
vr.state = vpDCSEsc
}
case vpDCSEsc:
vr.buf = append(vr.buf, b)
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
case vpSOSPMAPC:
vr.buf = append(vr.buf, b)
if b == 0x1b {
vr.state = vpSOSPMAPCEsc
}
case vpSOSPMAPCEsc:
vr.buf = append(vr.buf, b)
vr.pending.Write(vr.buf)
vr.state = vpNormal
vr.buf = vr.buf[:0]
}
}
func (vr *viewportRenderer) emitCSI() {
if len(vr.buf) < 3 {
vr.pending.Write(vr.buf)
return
}
final := vr.buf[len(vr.buf)-1]
params := vr.buf[2 : len(vr.buf)-1]
if final == 'h' || final == 'l' {
if isAltScreenMode(params) {
return
}
}
switch final {
case 'J':
n, ok := parseOneParam(params, 0)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
switch n {
case 2, 3:
vr.pending.WriteString(vr.clearViewport())
default:
vr.pending.Write(vr.shifter.Shift(vr.buf))
}
case 'K':
n, ok := parseOneParam(params, 0)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
vr.pending.WriteString(vr.clearLine(n))
default:
vr.pending.Write(vr.shifter.Shift(vr.buf))
}
vr.trackCSI(final, params)
}
func isAltScreenMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
switch p {
case "47", "1047", "1049":
return true
}
}
return false
}
func (vr *viewportRenderer) clearViewport() string {
var b strings.Builder
b.WriteString("\x1b7")
for r := uint16(0); r < vr.layout.childRows(); r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH%s", int(vr.layout.mainTop+r), int(vr.layout.mainLeft), strings.Repeat(" ", int(vr.layout.childCols())))
}
b.WriteString("\x1b8")
return b.String()
}
func (vr *viewportRenderer) clearLine(n int) string {
right := int(vr.layout.childCols())
if vr.col < 1 {
vr.col = 1
}
if vr.col > right {
vr.col = right
}
switch n {
case 0:
return "\x1b[" + strconv.Itoa(right-vr.col+1) + "X"
case 1:
return "\x1b7\r\x1b[" + strconv.Itoa(vr.col) + "X\x1b8"
case 2:
return "\x1b7\r\x1b[" + strconv.Itoa(right) + "X\x1b8"
default:
return "\x1b[" + strconv.Itoa(right-vr.col+1) + "X"
}
}
func (vr *viewportRenderer) advancePrintable(b byte) {
switch b {
case '\r':
vr.col = 1
case '\n':
vr.row++
case '\b':
if vr.col > 1 {
vr.col--
}
case '\t':
vr.col += 8 - ((vr.col - 1) % 8)
default:
if b >= 0x20 && b != 0x7f {
vr.col++
}
}
vr.clampCursor()
}
func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
switch final {
case 'H', 'f':
r, c, ok := parseTwoParams(params)
if ok {
vr.row, vr.col = r, c
}
case 'G', '`':
c, ok := parseOneParam(params, 1)
if ok {
vr.col = c
}
case 'd':
r, ok := parseOneParam(params, 1)
if ok {
vr.row = r
}
case 'A':
n, ok := parseOneParam(params, 1)
if ok {
vr.row -= n
}
case 'B', 'e':
n, ok := parseOneParam(params, 1)
if ok {
vr.row += n
}
case 'C', 'a':
n, ok := parseOneParam(params, 1)
if ok {
vr.col += n
}
case 'D':
n, ok := parseOneParam(params, 1)
if ok {
vr.col -= n
}
}
vr.clampCursor()
}
func (vr *viewportRenderer) clampCursor() {
if vr.row < 1 {
vr.row = 1
}
if vr.col < 1 {
vr.col = 1
}
if max := int(vr.layout.childRows()); vr.row > max {
vr.row = max
}
if max := int(vr.layout.childCols()); vr.col > max {
vr.col = max
}
}

View File

@@ -0,0 +1,63 @@
package app
import (
"strings"
"testing"
)
func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[2;1H" {
t.Fatalf("CUP home: got %q", got)
}
}
func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?1049hb\x1b[?1049lc")))
if got != "abc" {
t.Fatalf("alt-screen toggles: got %q", got)
}
}
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got)
}
if strings.Count(got, " ") != 3 {
t.Fatalf("clear rows: got %q", got)
}
if !strings.Contains(got, "\x1b[2;1H") || !strings.Contains(got, "\x1b[4;1H") {
t.Fatalf("clear did not target viewport rows: %q", got)
}
}
func TestViewportRendererClearLineUsesEraseChars(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[K")))
if strings.Contains(got, "\x1b[K") {
t.Fatalf("host clear-line leaked through: %q", got)
}
if got != "\x1b[20X" {
t.Fatalf("clear-line: got %q want ECH", got)
}
}
func TestViewportRendererClearLineStopsAtViewportRight(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[10G\x1b[K")))
if !strings.HasSuffix(got, "\x1b[11X") {
t.Fatalf("clear-line from col 10 should erase 11 cells: %q", got)
}
}
func TestViewportRendererTracksPrintableCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("hello\x1b[K")))
if !strings.HasSuffix(got, "\x1b[15X") {
t.Fatalf("clear-line after five chars should erase 15 cells: %q", got)
}
}

183
internal/mcp/mcp.go Normal file
View File

@@ -0,0 +1,183 @@
// Package mcp is patterm's in-process MCP server and the stdio proxy
// subcommand that spawned children connect through. SPEC §7 + §10.
//
// v1 stubs out the server: it listens on the per-PID socket, accepts
// connections from `patterm mcp-stdio` proxies, and returns a "not
// implemented" error for every tool call. The plumbing is in place so
// later milestones (suggested build order §15 step 4 onwards) can fill
// in real tool handlers without touching the lifecycle code.
package mcp
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strconv"
"sync"
)
// Server is patterm's in-process MCP server. SPEC §10: bound to a
// per-PID unix socket under $XDG_RUNTIME_DIR/patterm/<pid>.sock.
type Server struct {
socket string
listener net.Listener
mu sync.Mutex
closed bool
host ToolHost
}
// SocketPath returns the per-PID socket path with the standard fallback.
// SPEC §3.
func SocketPath() (string, error) {
pid := strconv.Itoa(os.Getpid())
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
dir := filepath.Join(runtime, "patterm")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("mcp: mkdir %s: %w", dir, err)
}
return filepath.Join(dir, pid+".sock"), nil
}
return filepath.Join("/tmp", "patterm-"+pid+".sock"), nil
}
// Start opens the per-PID socket and serves JSON-RPC over it. The
// returned Server can be Close()d on shutdown.
func Start() (*Server, error) {
path, err := SocketPath()
if err != nil {
return nil, err
}
_ = os.Remove(path)
ln, err := net.Listen("unix", path)
if err != nil {
return nil, fmt.Errorf("mcp: listen %s: %w", path, err)
}
_ = os.Chmod(path, 0o600)
s := &Server{socket: path, listener: ln}
go s.acceptLoop()
return s, nil
}
func (s *Server) Socket() string { return s.socket }
func (s *Server) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
_ = s.listener.Close()
_ = os.Remove(s.socket)
return nil
}
func (s *Server) acceptLoop() {
for {
conn, err := s.listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
continue
}
go s.handleConn(conn)
}
}
// handleConn reads newline-delimited JSON-RPC requests from a connected
// child and dispatches them. The first line carries the per-spawn
// identity token (SPEC §10); we resolve it to a child id and stash that
// as the caller for every subsequent tool call.
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
r := bufio.NewReader(conn)
var callerID string
greeting, err := r.ReadBytes('\n')
if err != nil {
return
}
if tok := greetingIdentity(greeting); tok != "" {
s.mu.Lock()
host := s.host
s.mu.Unlock()
if host != nil {
callerID = host.ResolveCallerIdentity(tok)
}
} else {
// Treat as a real request from an unknown caller.
resp := s.dispatch("", greeting)
resp = append(resp, '\n')
if _, werr := conn.Write(resp); werr != nil {
return
}
}
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
resp := s.dispatch(callerID, line)
resp = append(resp, '\n')
if _, werr := conn.Write(resp); werr != nil {
return
}
}
if err != nil {
return
}
}
}
func greetingIdentity(b []byte) string {
var probe struct {
Identity string `json:"patterm_identity"`
}
if err := json.Unmarshal(b, &probe); err != nil {
return ""
}
return probe.Identity
}
// RunStdioProxy is the entry point for `patterm mcp-stdio`. It opens
// the per-PID socket and shuttles bytes between os.Stdin/os.Stdout and
// the socket. SPEC §10: the vendor CLI thinks it's launching a normal
// stdio MCP server; this proxy forwards JSON-RPC to the running
// patterm process.
func RunStdioProxy(socket, identity string) error {
conn, err := net.Dial("unix", socket)
if err != nil {
return fmt.Errorf("dial %s: %w", socket, err)
}
defer conn.Close()
// Send a one-line greeting carrying the identity so the server
// knows which child it's talking to. Format: {"patterm_identity":
// "<token>"} + newline. Real protocol handshake is a later
// milestone.
greeting := map[string]string{"patterm_identity": identity}
gb, _ := json.Marshal(greeting)
gb = append(gb, '\n')
if _, err := conn.Write(gb); err != nil {
return fmt.Errorf("greeting: %w", err)
}
errCh := make(chan error, 2)
go func() {
_, err := io.Copy(conn, os.Stdin)
errCh <- err
}()
go func() {
_, err := io.Copy(os.Stdout, conn)
errCh <- err
}()
<-errCh
return nil
}

347
internal/mcp/tools.go Normal file
View File

@@ -0,0 +1,347 @@
package mcp
import (
"encoding/json"
"errors"
"fmt"
"syscall"
"github.com/harrybrwn/patterm/internal/scratchpad"
)
// ToolHost is the interface the in-process server uses to reach the
// running patterm process's state. The app package implements this so
// internal/mcp doesn't import internal/app (which would be a cycle).
type ToolHost interface {
Children() []ChildInfo
Spawn(callerID, name string, argv []string, shell bool) (ChildInfo, error)
SpawnAgent(callerID, presetName, displayName, initialPrompt string) (ChildInfo, error)
ReadOutput(callerID, childID, mode string, sinceOffset int) (content string, newOffset int, err error)
SendInput(callerID, childID string, payload []byte, appendNewline bool) error
Kill(callerID, childID string, sig syscall.Signal) error
SendMessageTo(callerID, targetID, message string) error
ReportToParent(callerID, message string) error
TimerWait(callerID string, seconds float64, label string) (string, error)
WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (matched bool, snippet string, err error)
RequestHumanAttention(callerID, childID, reason string) error
Scratchpads() *scratchpad.Store
// ResolveCallerIdentity translates a per-spawn identity token into
// the child ID the server stores in its connection state.
ResolveCallerIdentity(identity string) string
// PolicyCheck — SPEC §9. Returns "allow" / "punt" / "unknown" for
// a candidate auto-answer prompt the orchestrator is reading.
PolicyCheck(prompt string) string
}
// ChildInfo is what list_children / spawn_process / spawn_agent return.
// Matches SPEC §7 shape plus the §11 idle exposure.
type ChildInfo struct {
ID string `json:"child_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
ExitCode int `json:"exit_code,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"`
ParentID string `json:"parent_id,omitempty"`
}
func (s *Server) SetHost(h ToolHost) {
s.mu.Lock()
defer s.mu.Unlock()
s.host = h
}
// dispatch routes a single JSON-RPC request. callerID is the ID of the
// child that owns this connection (resolved at greeting time).
func (s *Server) dispatch(callerID string, req []byte) []byte {
var msg struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(req, &msg); err != nil {
return jsonRPCError(nil, -32700, "parse error: "+err.Error())
}
s.mu.Lock()
host := s.host
s.mu.Unlock()
if host == nil {
return jsonRPCError(msg.ID, -32000, "patterm: tool host not initialized")
}
result, code, errMsg := callTool(host, callerID, msg.Method, msg.Params)
if errMsg != "" {
return jsonRPCError(msg.ID, code, errMsg)
}
return jsonRPCResult(msg.ID, result)
}
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string) {
switch method {
case "list_children":
return h.Children(), 0, ""
case "spawn_process":
var p struct {
Preset string `json:"preset"`
Argv []string `json:"argv"`
Shell bool `json:"shell"`
Name string `json:"name"`
WorkingDir string `json:"working_dir"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
// Preset-by-name is the preferred path per SPEC §7; argv is the
// escape hatch. We don't load process presets here — the host
// is the source of truth — so a named preset call is rejected
// unless the caller also supplied argv. (Wiring full preset
// resolution into MCP is a small follow-up; the host's palette
// path covers the named case today.)
if len(p.Argv) == 0 {
return nil, -32602, "spawn_process: argv required"
}
ci, err := h.Spawn(callerID, p.Name, p.Argv, p.Shell)
if err != nil {
return nil, -32000, err.Error()
}
return ci, 0, ""
case "spawn_agent":
var p struct {
Preset string `json:"preset"`
InitialPrompt string `json:"initial_prompt"`
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
if p.Preset == "" {
return nil, -32602, "spawn_agent: preset required"
}
ci, err := h.SpawnAgent(callerID, p.Preset, p.Name, p.InitialPrompt)
if err != nil {
return nil, -32000, err.Error()
}
return ci, 0, ""
case "read_output":
var p struct {
ChildID string `json:"child_id"`
Mode string `json:"mode"`
SinceOffset int `json:"since_offset"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
if p.Mode == "" {
p.Mode = "grid"
}
content, newOff, err := h.ReadOutput(callerID, p.ChildID, p.Mode, p.SinceOffset)
if err != nil {
return nil, -32000, err.Error()
}
return map[string]any{
"content": content,
"new_offset": newOff,
"mode": p.Mode,
}, 0, ""
case "send_input":
var p struct {
ChildID string `json:"child_id"`
Input string `json:"input"`
AppendNewline *bool `json:"append_newline"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
appendNL := true
if p.AppendNewline != nil {
appendNL = *p.AppendNewline
}
if err := h.SendInput(callerID, p.ChildID, []byte(p.Input), appendNL); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "kill":
var p struct {
ChildID string `json:"child_id"`
Signal int `json:"signal"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
sig := syscall.Signal(p.Signal)
if sig == 0 {
sig = syscall.SIGTERM
}
if err := h.Kill(callerID, p.ChildID, sig); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "send_message_to":
var p struct {
Target string `json:"target"`
Message string `json:"message"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
if err := h.SendMessageTo(callerID, p.Target, p.Message); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "report_to_parent":
var p struct {
Message string `json:"message"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
if err := h.ReportToParent(callerID, p.Message); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "timer_wait":
var p struct {
Seconds float64 `json:"seconds"`
Label string `json:"label"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
id, err := h.TimerWait(callerID, p.Seconds, p.Label)
if err != nil {
return nil, -32000, err.Error()
}
return map[string]string{"timer_id": id}, 0, ""
case "wait_for_pattern":
var p struct {
ChildID string `json:"child_id"`
Pattern string `json:"pattern"`
TimeoutSeconds float64 `json:"timeout_seconds"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
matched, snippet, err := h.WaitForPattern(callerID, p.ChildID, p.Pattern, p.TimeoutSeconds)
if err != nil {
return nil, -32000, err.Error()
}
return map[string]any{"matched": matched, "snippet": snippet}, 0, ""
case "request_human_attention":
var p struct {
ChildID string `json:"child_id"`
Reason string `json:"reason"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
if err := h.RequestHumanAttention(callerID, p.ChildID, p.Reason); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
case "scratchpad_list":
entries, err := h.Scratchpads().List()
if err != nil {
return nil, -32000, err.Error()
}
return entries, 0, ""
case "scratchpad_read":
var p struct {
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
content, rev, err := h.Scratchpads().Read(p.Name)
if err != nil {
return nil, -32000, err.Error()
}
return map[string]any{"content": content, "revision": rev}, 0, ""
case "scratchpad_write":
var p struct {
Name string `json:"name"`
Content string `json:"content"`
ExpectedRevision string `json:"expected_revision"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
rev, err := h.Scratchpads().Write(p.Name, p.Content, p.ExpectedRevision)
if err != nil {
return nil, -32000, err.Error()
}
return map[string]any{"revision": rev}, 0, ""
case "policy_check":
var p struct {
Prompt string `json:"prompt"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
return map[string]string{"decision": h.PolicyCheck(p.Prompt)}, 0, ""
case "scratchpad_append":
var p struct {
Name string `json:"name"`
Content string `json:"content"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, -32602, err.Error()
}
if err := h.Scratchpads().Append(p.Name, p.Content); err != nil {
return nil, -32000, err.Error()
}
return "ok", 0, ""
}
return nil, -32601, "method not found: " + method
}
func unmarshalParams(raw json.RawMessage, out any) error {
if len(raw) == 0 {
return errors.New("missing params")
}
return json.Unmarshal(raw, out)
}
func jsonRPCResult(id json.RawMessage, result any) []byte {
resp := map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": result,
}
b, _ := json.Marshal(resp)
return b
}
func jsonRPCError(id json.RawMessage, code int, message string) []byte {
resp := map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{
"code": code,
"message": message,
},
}
b, _ := json.Marshal(resp)
return b
}
// Compile-time guard: every dispatch path is covered. fmt is imported
// only so future error wrapping can land without re-adding the import.
var _ = fmt.Sprintf

166
internal/policy/policy.go Normal file
View File

@@ -0,0 +1,166 @@
// Package policy implements SPEC §9 permissions hooks.
//
// patterm doesn't enforce permissions on the agent's behalf — the
// orchestrator is the policy actor. But patterm ships a config that
// surfaces the project's deny-list to the orchestrator (via
// scratchpad_read("policy.md")) and exposes a Should() helper future
// MCP middleware can call to short-circuit obviously-dangerous prompts.
//
// File location: $XDG_CONFIG_HOME/patterm/policy.json (global default;
// per-project override at <project>/.patterm/policy.json is a v2
// follow-up).
package policy
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
"sync"
)
// Decision is what Should() returns for a candidate auto-answer.
type Decision string
const (
// Allow: the prompt is in the always-safe allowlist; auto-answer.
Allow Decision = "allow"
// PuntToHuman: the prompt matches the deny-list; the orchestrator
// MUST call request_human_attention instead of auto-answering.
PuntToHuman Decision = "punt"
// Unknown: no rule applies. SPEC §9 says default is to punt; we
// keep this distinct so callers know it's a default, not a match.
Unknown Decision = "unknown"
)
type Policy struct {
// Allowlist patterns: prompts matching ANY of these are safe to
// auto-answer.
AllowPatterns []string `json:"allow_patterns"`
// Deny patterns: prompts matching ANY of these MUST be punted to
// the human. Default seed below covers SPEC §9 examples (writes,
// deletes, sudo, package install, broad shell).
DenyPatterns []string `json:"deny_patterns"`
mu sync.Mutex
compiledAOK bool
allowRE []*regexp.Regexp
denyRE []*regexp.Regexp
}
// Default returns the seeded policy that ships out of the box.
func Default() *Policy {
return &Policy{
AllowPatterns: []string{
`(?i)read.* from .*\?`,
`(?i)open .* in editor\?`,
`(?i)show diff\?`,
},
DenyPatterns: []string{
`(?i)sudo`,
`(?i)rm -rf`,
`(?i)delete .*permanently`,
`(?i)install package`,
`(?i)pip install`,
`(?i)npm install -g`,
`(?i)curl .* \| .*sh`,
`(?i)wget .* \| .*sh`,
`(?i)force.push`,
`(?i)git push --force`,
`(?i)drop (table|database)`,
`(?i)\.ssh/`,
`(?i)\.aws/credentials`,
`(?i)\.env\b`,
},
}
}
// Load reads the user's policy, falling back to Default if absent.
// Errors other than ENOENT are returned.
func Load() (*Policy, error) {
p, err := PathFor()
if err != nil {
return nil, err
}
body, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
pol := Default()
_ = Save(pol)
return pol, nil
}
return nil, err
}
var pol Policy
if err := json.Unmarshal(body, &pol); err != nil {
return nil, err
}
return &pol, nil
}
// Save writes p to the standard location, creating directories.
func Save(p *Policy) error {
path, err := PathFor()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
body, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
return os.WriteFile(path, body, 0o600)
}
func PathFor() (string, error) {
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
return filepath.Join(h, "patterm", "policy.json"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "patterm", "policy.json"), nil
}
// Should classifies a candidate auto-answer prompt the orchestrator is
// reading from a sub-agent's grid.
func (p *Policy) Should(promptText string) Decision {
p.mu.Lock()
defer p.mu.Unlock()
p.ensureCompiledLocked()
for _, re := range p.denyRE {
if re.MatchString(promptText) {
return PuntToHuman
}
}
for _, re := range p.allowRE {
if re.MatchString(promptText) {
return Allow
}
}
return Unknown
}
func (p *Policy) ensureCompiledLocked() {
if p.compiledAOK {
return
}
p.allowRE = make([]*regexp.Regexp, 0, len(p.AllowPatterns))
for _, s := range p.AllowPatterns {
if re, err := regexp.Compile(s); err == nil {
p.allowRE = append(p.allowRE, re)
}
}
p.denyRE = make([]*regexp.Regexp, 0, len(p.DenyPatterns))
for _, s := range p.DenyPatterns {
if re, err := regexp.Compile(s); err == nil {
p.denyRE = append(p.denyRE, re)
}
}
p.compiledAOK = true
}

254
internal/preset/preset.go Normal file
View File

@@ -0,0 +1,254 @@
// Package preset loads user-editable JSON files that describe how to
// launch agents and processes. SPEC §10: every spawnable thing is a
// preset; patterm has no hard-coded agent or process types.
package preset
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Kind is "agent" or "process".
type Kind string
const (
KindAgent Kind = "agent"
KindProcess Kind = "process"
)
// Preset is one loaded preset file. Agent-only fields stay zero on
// process presets and vice versa.
type Preset struct {
// Source path (informational; not serialized).
Path string `json:"-"`
Kind Kind `json:"-"`
Name string `json:"name"`
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
// Process-only.
Shell bool `json:"shell,omitempty"`
// Agent-only. SPEC §10.
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
}
// MCPInjection covers the three strategies SPEC §10 enumerates: a CLI
// flag (claude --mcp-config ...), an external config file we merge into
// (codex ~/.codex/config.toml), or an env var.
type MCPInjection struct {
Kind string `json:"kind"` // "flag" | "config_file" | "env_var"
Flag string `json:"flag,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
Path string `json:"path,omitempty"`
MergeKey string `json:"merge_key,omitempty"`
Var string `json:"var,omitempty"`
}
// ReadySignal lets a preset override the default 1s-idle heuristic.
type ReadySignal struct {
IdleMS int `json:"idle_ms,omitempty"`
Pattern string `json:"pattern,omitempty"`
}
// Set is what the palette consumes.
type Set struct {
Agents []*Preset
Processes []*Preset
}
// Load scans the standard locations under $XDG_CONFIG_HOME/patterm/
// presets/{agents,processes}/*.json. Unknown files are skipped with a
// warning to stderr; the spec is forgiving here.
func Load() (Set, error) {
base, err := ConfigDir()
if err != nil {
return Set{}, err
}
if err := os.MkdirAll(base, 0o700); err != nil {
return Set{}, fmt.Errorf("preset: mkdir %s: %w", base, err)
}
// Make sure the default-preset files exist on first run. Idempotent.
if err := ensureDefaults(base); err != nil {
return Set{}, err
}
agents, err := loadDir(filepath.Join(base, "presets", "agents"), KindAgent)
if err != nil {
return Set{}, err
}
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindProcess)
if err != nil {
return Set{}, err
}
return Set{Agents: agents, Processes: procs}, nil
}
// ConfigDir resolves $XDG_CONFIG_HOME/patterm (with the conventional
// fallback to ~/.config/patterm).
func ConfigDir() (string, error) {
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
return filepath.Join(h, "patterm"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "patterm"), nil
}
func loadDir(dir string, kind Kind) ([]*Preset, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("preset: mkdir %s: %w", dir, err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("preset: read %s: %w", dir, err)
}
var out []*Preset
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(dir, e.Name())
p, err := loadFile(path, kind)
if err != nil {
fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err)
continue
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func loadFile(path string, kind Kind) (*Preset, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var p Preset
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
if p.Name == "" {
return nil, errors.New("missing 'name'")
}
if len(p.Argv) == 0 && !p.Shell {
return nil, errors.New("missing 'argv'")
}
p.Path = path
p.Kind = kind
return &p, nil
}
// ResolvedArgv returns the argv to actually exec, handling the
// process-preset "shell: true" case (SPEC §10).
func (p *Preset) ResolvedArgv() []string {
if p.Shell && len(p.Argv) > 0 {
return []string{"sh", "-lc", strings.Join(p.Argv, " ")}
}
return p.Argv
}
// ensureDefaults writes default agent presets (claude/codex/opencode)
// and a sample process preset on first run. Never overwrites existing
// user files.
func ensureDefaults(base string) error {
defaults := []struct {
rel string
body string
}{
{
"presets/agents/claude.json",
`{
"name": "claude",
"argv": ["claude"],
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
"ready_signal": { "idle_ms": 1000 },
"chrome_trim_hints": [
"^Welcome to Claude Code",
"^/help for help",
"^cwd:",
"^\\s*│\\s*>",
"^\\s*╭─+╮$",
"^\\s*╰─+╯$",
"^\\? for shortcuts"
]
}
`,
},
{
"presets/agents/codex.json",
`{
"name": "codex",
"argv": ["codex"],
"mcp_injection": { "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" },
"ready_signal": { "idle_ms": 1000 },
"chrome_trim_hints": [
"^OpenAI Codex",
"^\\s*model:",
"^\\s*workdir:",
"^>_$",
"^\\s*▌"
]
}
`,
},
{
"presets/agents/opencode.json",
`{
"name": "opencode",
"argv": ["opencode"],
"mcp_injection": { "kind": "config_file", "path": "~/.config/opencode/opencode.json", "merge_key": "mcp" },
"ready_signal": { "idle_ms": 1000 },
"chrome_trim_hints": [
"^\\s*█",
"^\\s*opencode v",
"^\\s*~/",
"^\\s*>_"
]
}
`,
},
{
"presets/processes/shell.json",
`{
"name": "shell",
"argv": ["__SHELL__"]
}
`,
},
}
for _, d := range defaults {
full := filepath.Join(base, d.rel)
if _, err := os.Stat(full); err == nil {
continue
}
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
return err
}
body := d.body
if strings.Contains(body, "__SHELL__") {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
body = strings.ReplaceAll(body, "__SHELL__", shell)
}
if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,32 @@
// Package projectkey turns a project directory into the stable key
// patterm uses to name its scratchpad directory under $XDG_DATA_HOME.
// SPEC §3.
//
// Two invocations from the same realpath must produce the same key.
// The key is only used as a directory name on disk — there is no
// daemon to look up.
package projectkey
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path/filepath"
)
// Key derives the 16-char hex key from the realpath of dir.
func Key(dir string) (string, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("projectkey: abs %q: %w", dir, err)
}
resolved, err := filepath.EvalSymlinks(abs)
if err != nil {
// Directory may not exist yet; fall back to the absolute path.
// The key stays stable; downstream code will fail later when it
// tries to chdir or write into the dir.
resolved = abs
}
sum := sha256.Sum256([]byte(resolved))
return hex.EncodeToString(sum[:8]), nil
}

142
internal/pty/pty.go Normal file
View File

@@ -0,0 +1,142 @@
// Package pty wraps creack/pty with the small surface the spike needs.
package pty
import (
"fmt"
"io"
"os"
"os/exec"
cpty "github.com/creack/pty"
)
// PTY holds a child process attached to a pseudo-terminal master fd.
type PTY struct {
master *os.File
cmd *exec.Cmd
}
// Start spawns argv with stdin/stdout/stderr attached to a new PTY sized
// (cols, rows). The returned PTY exposes the master fd for the parent to
// read from and write to.
func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) {
if len(argv) == 0 {
return nil, fmt.Errorf("pty: empty argv")
}
cmd := exec.Command(argv[0], argv[1:]...)
if env != nil {
cmd.Env = ensureTerm(env)
} else {
// Default to the parent environment but force TERM to xterm-256color
// so child programs assume something modern and we observe SGR + alt
// screen sequences.
cmd.Env = ensureTerm(os.Environ())
}
ws := &cpty.Winsize{Cols: cols, Rows: rows}
master, err := cpty.StartWithSize(cmd, ws)
if err != nil {
return nil, fmt.Errorf("pty: start %v: %w", argv, err)
}
return &PTY{master: master, cmd: cmd}, nil
}
func (p *PTY) Read(b []byte) (int, error) {
if p.master == nil {
return 0, io.ErrClosedPipe
}
return p.master.Read(b)
}
func (p *PTY) Write(b []byte) (int, error) {
if p.master == nil {
return 0, io.ErrClosedPipe
}
return p.master.Write(b)
}
func (p *PTY) Resize(cols, rows uint16) error {
if p.master == nil {
return io.ErrClosedPipe
}
return cpty.Setsize(p.master, &cpty.Winsize{Cols: cols, Rows: rows})
}
// Wait blocks until the child exits and returns its exit error if any.
func (p *PTY) Wait() error {
if p.cmd == nil {
return nil
}
return p.cmd.Wait()
}
// Pid returns the child's PID, or -1 if the process is not running.
func (p *PTY) Pid() int {
if p.cmd == nil || p.cmd.Process == nil {
return -1
}
return p.cmd.Process.Pid
}
// Close terminates the child (best effort) and releases the master fd.
func (p *PTY) Close() error {
var firstErr error
if p.master != nil {
if err := p.master.Close(); err != nil && firstErr == nil {
firstErr = err
}
p.master = nil
}
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
return firstErr
}
// envDefaults are added to the child's environment unless the parent already
// set them. Modern agent TUIs check these and silently downgrade rendering
// when they're missing (no truecolor, ASCII-only banners, etc.).
var envDefaults = map[string]string{
"TERM": "xterm-256color",
"COLORTERM": "truecolor",
}
// envStrip names variables we DROP before launching a child. COLUMNS /
// LINES inherited from the parent shell describe the *host* terminal,
// not the PTY we created — when they leak through, TUIs that prefer
// env over TIOCGWINSZ render past the PTY's actual cell grid and
// overwrite our chrome.
var envStrip = map[string]bool{
"COLUMNS": true,
"LINES": true,
}
func ensureTerm(env []string) []string {
have := make(map[string]bool, len(envDefaults))
out := make([]string, 0, len(env)+len(envDefaults))
for _, kv := range env {
key := envKey(kv)
if envStrip[key] {
continue
}
if _, isDefault := envDefaults[key]; isDefault {
have[key] = true
}
out = append(out, kv)
}
for k, v := range envDefaults {
if !have[k] {
out = append(out, k+"="+v)
}
}
return out
}
func envKey(kv string) string {
for i := 0; i < len(kv); i++ {
if kv[i] == '=' {
return kv[:i]
}
}
return kv
}

View File

@@ -0,0 +1,138 @@
// Package scratchpad manages the project-scoped markdown files described
// in SPEC §3. Files live under
// $XDG_DATA_HOME/patterm/projects/<project-key>/scratchpads/. Last-write-
// wins with a revision token (SPEC §14).
package scratchpad
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Store is the per-project scratchpad directory.
type Store struct {
dir string
}
// Open returns a Store rooted at the SPEC §3 path for projectKey.
func Open(projectKey string) (*Store, error) {
base, err := DataDir()
if err != nil {
return nil, err
}
dir := filepath.Join(base, "projects", projectKey, "scratchpads")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("scratchpad: mkdir %s: %w", dir, err)
}
return &Store{dir: dir}, nil
}
// DataDir resolves $XDG_DATA_HOME/patterm with the conventional fallback.
func DataDir() (string, error) {
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
return filepath.Join(h, "patterm"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", "patterm"), nil
}
// Entry is what List returns; SPEC §7 `scratchpad_list` shape.
type Entry struct {
Name string
Size int64
ModifiedAt string // RFC3339; kept as string so MCP serialization is trivial later
}
func (s *Store) Dir() string { return s.dir }
func (s *Store) List() ([]Entry, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
out := make([]Entry, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, Entry{
Name: e.Name(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (s *Store) Read(name string) (content string, revision string, err error) {
p, err := s.safePath(name)
if err != nil {
return "", "", err
}
b, err := os.ReadFile(p)
if err != nil {
return "", "", err
}
return string(b), revisionOf(b), nil
}
// Write replaces the file's contents. expectedRevision, if non-empty,
// must match the current revision or the write is rejected (SPEC §14
// last-write-wins-with-token).
func (s *Store) Write(name, content, expectedRevision string) (string, error) {
p, err := s.safePath(name)
if err != nil {
return "", err
}
if expectedRevision != "" {
if cur, err := os.ReadFile(p); err == nil {
if revisionOf(cur) != expectedRevision {
return "", fmt.Errorf("scratchpad: revision mismatch")
}
}
}
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
return "", err
}
return revisionOf([]byte(content)), nil
}
func (s *Store) Append(name, content string) error {
p, err := s.safePath(name)
if err != nil {
return err
}
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(content)
return err
}
func (s *Store) safePath(name string) (string, error) {
if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
return "", errors.New("scratchpad: invalid name")
}
return filepath.Join(s.dir, name), nil
}
func revisionOf(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:6])
}

62
internal/vt/emulator.go Normal file
View File

@@ -0,0 +1,62 @@
// Package vt wraps a headless virtual terminal emulator behind a small
// Go interface. The intent is that all cgo to libghostty-vt is confined
// to the GhosttyEmulator implementation in this package.
package vt
// Screen identifies which buffer is currently displayed.
type Screen uint8
const (
ScreenPrimary Screen = iota
ScreenAlternate
)
// CursorState is a snapshot of cursor position and visibility.
type CursorState struct {
Col, Row uint16
Visible bool
}
// Emulator is the headless VT used by the daemon (and by the milestone-1 spike).
//
// Implementations are not required to be safe for concurrent use. The spike
// CLI funnels all calls through a single goroutine.
type Emulator interface {
// Write feeds bytes from the PTY master into the emulator. It returns
// the number of bytes consumed (always len(p) on success).
Write(p []byte) (int, error)
// Resize updates the emulator's cell grid. The caller is responsible
// for issuing TIOCSWINSZ on the PTY itself.
Resize(cols, rows uint16) error
// PlainText returns the active screen rendered as plain text, with
// soft-wrapped lines unwrapped and trailing whitespace trimmed.
PlainText() (string, error)
// ScreenText returns the active screen as fixed screen rows. Unlike
// PlainText, this preserves row boundaries so a host UI can repaint
// into a clipped viewport.
ScreenText() (string, error)
// SerializeVT returns the active screen as a VT byte sequence that, when
// written to a fresh terminal, reproduces the visible state (colours,
// styles, cursor, hyperlinks, etc.). Used as the daemon's "catch-up
// frame" for newly-attached clients.
SerializeVT() ([]byte, error)
// Cursor returns cursor position and visibility on the active screen.
Cursor() (CursorState, error)
// ActiveScreen reports whether we are on the primary or alternate buffer.
ActiveScreen() (Screen, error)
// OnWritePTY registers a callback that fires when the emulator wants
// to write bytes back to the PTY master (e.g. responses to DA / DSR
// queries). The callback runs synchronously inside Write and must not
// recurse into the emulator.
OnWritePTY(fn func([]byte))
// Close releases any underlying resources.
Close() error
}

390
internal/vt/ghostty.go Normal file
View File

@@ -0,0 +1,390 @@
//go:build !nocgo
package vt
/*
#cgo CFLAGS: -I${SRCDIR}/../../third_party/libghostty-vt/install/include -DGHOSTTY_STATIC
#cgo LDFLAGS: -L${SRCDIR}/../../third_party/libghostty-vt/install/lib -l:libghostty-vt.a -lm -lpthread
#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>
#include <ghostty/vt.h>
// Forward declaration of the exported Go callback (defined in ghostty_cgo.go).
extern void pattermGhosttyWritePty(GhosttyTerminal terminal,
void *userdata,
const uint8_t *data,
size_t len);
// Constant device-attributes response. vim/htop/etc. send DA1 (CSI c) on
// startup and block waiting for a reply; without this they hang forever.
// Conformance 62 = VT220-class with no advertised features, which is what
// kitty advertises and is enough for every TUI we've tested.
static bool patterm_da_cb(GhosttyTerminal terminal,
void *userdata,
GhosttyDeviceAttributes *out) {
(void)terminal; (void)userdata;
out->primary.conformance_level = 62;
out->primary.num_features = 0;
out->secondary.device_type = 1; // VT220
out->secondary.firmware_version = 100; // arbitrary
out->secondary.rom_cartridge = 0;
out->tertiary.unit_id = 0;
return true;
}
// Constant XTVERSION response. Some agent TUIs query this; without a
// response they wait. The GhosttyString memory must stay valid until the
// callback returns — a static const string is fine.
static GhosttyString patterm_xtversion_cb(GhosttyTerminal terminal,
void *userdata) {
(void)terminal; (void)userdata;
static const char ver[] = "patterm 0.0.1";
GhosttyString s;
s.ptr = (const uint8_t *)ver;
s.len = sizeof(ver) - 1;
return s;
}
// Constant ENQ response (empty). Some shells send ENQ on startup.
static GhosttyString patterm_enq_cb(GhosttyTerminal terminal, void *userdata) {
(void)terminal; (void)userdata;
GhosttyString s; s.ptr = NULL; s.len = 0;
return s;
}
// Helpers that hide casts cgo can't express directly.
static GhosttyResult patterm_install_write_pty(GhosttyTerminal t) {
return ghostty_terminal_set(t,
GHOSTTY_TERMINAL_OPT_WRITE_PTY,
(const void *)pattermGhosttyWritePty);
}
static GhosttyResult patterm_install_query_handlers(GhosttyTerminal t) {
GhosttyResult rc;
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES,
(const void *)patterm_da_cb);
if (rc != GHOSTTY_SUCCESS) return rc;
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_XTVERSION,
(const void *)patterm_xtversion_cb);
if (rc != GHOSTTY_SUCCESS) return rc;
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_ENQUIRY,
(const void *)patterm_enq_cb);
return rc;
}
static GhosttyResult patterm_set_userdata(GhosttyTerminal t, uintptr_t ud) {
return ghostty_terminal_set(t,
GHOSTTY_TERMINAL_OPT_USERDATA,
(const void *)ud);
}
static GhosttyFormatterTerminalOptions patterm_plain_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
opts.unwrap = true;
opts.trim = true;
return opts;
}
static GhosttyFormatterTerminalOptions patterm_screen_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
opts.unwrap = false;
opts.trim = false;
return opts;
}
// VT-format options for the daemon catch-up frame. Emits the active screen
// as VT escape sequences with cursor, style, hyperlink, mode, and tabstop
// state included so a freshly-attached client renders the existing screen
// correctly. unwrap/trim are NOT set — preserving wrap state and trailing
// cells is important for a faithful replay.
static GhosttyFormatterTerminalOptions patterm_vt_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_VT;
opts.extra.modes = true;
opts.extra.scrolling_region = true;
opts.extra.tabstops = true;
opts.extra.screen.cursor = true;
opts.extra.screen.style = true;
opts.extra.screen.hyperlink = true;
return opts;
}
*/
import "C"
import (
"errors"
"fmt"
"runtime"
"runtime/cgo"
"sync"
"sync/atomic"
"unsafe"
)
// GhosttyEmulator is the libghostty-vt-backed Emulator implementation.
//
// The C terminal handle is not thread-safe. Callers must serialise access;
// the spike CLI does this by running all calls on one goroutine, so the
// mutex below is a defensive belt-and-braces rather than the primary
// safety mechanism.
type GhosttyEmulator struct {
mu sync.Mutex
term C.GhosttyTerminal
handle cgo.Handle
closed bool
// onWrite is read from a cgo callback that is invoked synchronously
// from inside Write() — i.e. while e.mu is already held by this
// goroutine. Taking the mutex again would deadlock, so the field is
// stored atomically and read without the mutex.
onWrite atomic.Pointer[writeCallback]
cols uint16
rows uint16
}
// writeCallback wraps the callback func so it can sit in atomic.Pointer.
type writeCallback struct{ fn func([]byte) }
// NewGhosttyEmulator creates a new emulator with the given grid size.
func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
if cols == 0 || rows == 0 {
return nil, fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
}
e := &GhosttyEmulator{cols: cols, rows: rows}
opts := C.GhosttyTerminalOptions{
cols: C.uint16_t(cols),
rows: C.uint16_t(rows),
max_scrollback: 0,
}
if rc := C.ghostty_terminal_new(nil, &e.term, opts); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: ghostty_terminal_new failed: %s", ghosttyResultStr(rc))
}
// Park ourselves in cgo's handle table so the C callback can find us.
e.handle = cgo.NewHandle(e)
if rc := C.patterm_set_userdata(e.term, C.uintptr_t(uintptr(e.handle))); rc != C.GHOSTTY_SUCCESS {
e.handle.Delete()
C.ghostty_terminal_free(e.term)
return nil, fmt.Errorf("vt: set userdata failed: %s", ghosttyResultStr(rc))
}
if rc := C.patterm_install_write_pty(e.term); rc != C.GHOSTTY_SUCCESS {
e.handle.Delete()
C.ghostty_terminal_free(e.term)
return nil, fmt.Errorf("vt: install write_pty failed: %s", ghosttyResultStr(rc))
}
if rc := C.patterm_install_query_handlers(e.term); rc != C.GHOSTTY_SUCCESS {
e.handle.Delete()
C.ghostty_terminal_free(e.term)
return nil, fmt.Errorf("vt: install query handlers failed: %s", ghosttyResultStr(rc))
}
// Make sure Close runs even if the caller forgets. Programs that hold
// the emulator for their full lifetime can ignore this.
runtime.SetFinalizer(e, func(x *GhosttyEmulator) { _ = x.Close() })
return e, nil
}
func (e *GhosttyEmulator) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return 0, errors.New("vt: emulator closed")
}
C.ghostty_terminal_vt_write(
e.term,
(*C.uint8_t)(unsafe.Pointer(&p[0])),
C.size_t(len(p)),
)
return len(p), nil
}
func (e *GhosttyEmulator) Resize(cols, rows uint16) error {
if cols == 0 || rows == 0 {
return fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
}
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("vt: emulator closed")
}
rc := C.ghostty_terminal_resize(e.term,
C.uint16_t(cols), C.uint16_t(rows),
0, 0, // pixel dimensions: we don't use image protocols in the spike
)
if rc != C.GHOSTTY_SUCCESS {
return fmt.Errorf("vt: resize failed: %s", ghosttyResultStr(rc))
}
e.cols, e.rows = cols, rows
return nil
}
func (e *GhosttyEmulator) PlainText() (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return "", errors.New("vt: emulator closed")
}
opts := C.patterm_plain_fmt_opts()
return e.formatPlainLocked(opts)
}
func (e *GhosttyEmulator) ScreenText() (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return "", errors.New("vt: emulator closed")
}
opts := C.patterm_screen_fmt_opts()
return e.formatPlainLocked(opts)
}
func (e *GhosttyEmulator) formatPlainLocked(opts C.GhosttyFormatterTerminalOptions) (string, error) {
var fmtr C.GhosttyFormatter
if rc := C.ghostty_formatter_terminal_new(nil, &fmtr, e.term, opts); rc != C.GHOSTTY_SUCCESS {
return "", fmt.Errorf("vt: formatter_terminal_new failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_formatter_free(fmtr)
var buf *C.uint8_t
var n C.size_t
if rc := C.ghostty_formatter_format_alloc(fmtr, nil, &buf, &n); rc != C.GHOSTTY_SUCCESS {
return "", fmt.Errorf("vt: format_alloc failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_free(nil, buf, n)
if buf == nil || n == 0 {
return "", nil
}
return C.GoStringN((*C.char)(unsafe.Pointer(buf)), C.int(n)), nil
}
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil, errors.New("vt: emulator closed")
}
opts := C.patterm_vt_fmt_opts()
var fmtr C.GhosttyFormatter
if rc := C.ghostty_formatter_terminal_new(nil, &fmtr, e.term, opts); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: formatter_terminal_new (vt) failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_formatter_free(fmtr)
var buf *C.uint8_t
var n C.size_t
if rc := C.ghostty_formatter_format_alloc(fmtr, nil, &buf, &n); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: format_alloc (vt) failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_free(nil, buf, n)
if buf == nil || n == 0 {
return nil, nil
}
return C.GoBytes(unsafe.Pointer(buf), C.int(n)), nil
}
func (e *GhosttyEmulator) Cursor() (CursorState, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return CursorState{}, errors.New("vt: emulator closed")
}
var col, row C.uint16_t
var visible C.bool
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_X, unsafe.Pointer(&col)); rc != C.GHOSTTY_SUCCESS {
return CursorState{}, fmt.Errorf("vt: get cursor_x failed: %s", ghosttyResultStr(rc))
}
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_Y, unsafe.Pointer(&row)); rc != C.GHOSTTY_SUCCESS {
return CursorState{}, fmt.Errorf("vt: get cursor_y failed: %s", ghosttyResultStr(rc))
}
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, unsafe.Pointer(&visible)); rc != C.GHOSTTY_SUCCESS {
return CursorState{}, fmt.Errorf("vt: get cursor_visible failed: %s", ghosttyResultStr(rc))
}
return CursorState{Col: uint16(col), Row: uint16(row), Visible: bool(visible)}, nil
}
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return 0, errors.New("vt: emulator closed")
}
var s C.GhosttyTerminalScreen
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN, unsafe.Pointer(&s)); rc != C.GHOSTTY_SUCCESS {
return 0, fmt.Errorf("vt: get active_screen failed: %s", ghosttyResultStr(rc))
}
if s == C.GHOSTTY_TERMINAL_SCREEN_ALTERNATE {
return ScreenAlternate, nil
}
return ScreenPrimary, nil
}
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {
if fn == nil {
e.onWrite.Store(nil)
return
}
e.onWrite.Store(&writeCallback{fn: fn})
}
// writePTYCallback is called from the exported cgo shim. It runs inside a
// vt_write() that already owns e.mu, so it MUST NOT take the mutex.
func (e *GhosttyEmulator) writePTYCallback() func([]byte) {
cb := e.onWrite.Load()
if cb == nil {
return nil
}
return cb.fn
}
func (e *GhosttyEmulator) Close() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil
}
e.closed = true
runtime.SetFinalizer(e, nil)
C.ghostty_terminal_free(e.term)
e.term = nil
e.handle.Delete()
return nil
}
func ghosttyResultStr(rc C.GhosttyResult) string {
switch rc {
case C.GHOSTTY_SUCCESS:
return "SUCCESS"
case C.GHOSTTY_OUT_OF_MEMORY:
return "OUT_OF_MEMORY"
case C.GHOSTTY_INVALID_VALUE:
return "INVALID_VALUE"
case C.GHOSTTY_OUT_OF_SPACE:
return "OUT_OF_SPACE"
case C.GHOSTTY_NO_VALUE:
return "NO_VALUE"
default:
return fmt.Sprintf("unknown(%d)", int(rc))
}
}
// Compile-time assertion that GhosttyEmulator satisfies Emulator.
var _ Emulator = (*GhosttyEmulator)(nil)

View File

@@ -0,0 +1,38 @@
//go:build !nocgo
package vt
/*
// This preamble must contain DECLARATIONS ONLY — cgo refuses to compile
// a file that both defines functions in its preamble and has //export
// directives. The helper definitions live in ghostty.go's preamble.
#include <stdint.h>
#include <stddef.h>
#include <ghostty/vt.h>
*/
import "C"
import (
"runtime/cgo"
"unsafe"
)
//export pattermGhosttyWritePty
func pattermGhosttyWritePty(_ C.GhosttyTerminal, userdata unsafe.Pointer, data *C.uint8_t, length C.size_t) {
if userdata == nil || data == nil || length == 0 {
return
}
h := cgo.Handle(uintptr(userdata))
v := h.Value()
e, ok := v.(*GhosttyEmulator)
if !ok || e == nil {
return
}
cb := e.writePTYCallback()
if cb == nil {
return
}
buf := C.GoBytes(unsafe.Pointer(data), C.int(length))
cb(buf)
}

View File

@@ -0,0 +1,30 @@
//go:build nocgo
// This file provides a stub GhosttyEmulator for `go vet` / `go build`
// invocations that pass the `nocgo` build tag, so the rest of the Go code can
// be checked without `libghostty-vt` being installed. The stub fails at
// construction time — there is no functional emulator in `nocgo` builds.
package vt
import "errors"
type GhosttyEmulator struct{}
func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
return nil, errors.New("vt: built with -tags nocgo; libghostty-vt is unavailable")
}
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
func (e *GhosttyEmulator) Close() error { return nil }
var errStub = errors.New("vt: built with -tags nocgo")
var _ Emulator = (*GhosttyEmulator)(nil)