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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/third_party/libghostty-vt/source/
/third_party/libghostty-vt/install/
*.bytes
*.crash
spike-report-*.txt
/.zig-cache/
/bin/
/spike

42
Makefile Normal file
View File

@@ -0,0 +1,42 @@
SHELL := /bin/bash
ROOT := $(abspath .)
VENDOR := $(ROOT)/third_party/libghostty-vt
SOURCE := $(VENDOR)/source
INSTALL := $(VENDOR)/install
COMMIT := $(shell cat $(VENDOR)/COMMIT)
.PHONY: deps deps-fetch deps-build clean-deps spike patterm test
# `make deps` fetches and builds libghostty-vt at the pinned commit.
# Re-runs are idempotent on success; touch $(VENDOR)/COMMIT to force a rebuild.
deps: $(INSTALL)/lib/libghostty-vt.a
$(SOURCE)/.git/HEAD:
@echo ">> cloning ghostty-org/ghostty @ $(COMMIT)"
@rm -rf $(SOURCE)
@git clone --filter=blob:none https://github.com/ghostty-org/ghostty.git $(SOURCE)
@cd $(SOURCE) && git checkout --detach $(COMMIT)
deps-fetch: $(SOURCE)/.git/HEAD
$(INSTALL)/lib/libghostty-vt.a: $(SOURCE)/.git/HEAD
@command -v zig >/dev/null || { echo "ERROR: zig not on PATH (need >=0.15.2 to build libghostty-vt)"; exit 1; }
@echo ">> building libghostty-vt with zig"
@cd $(SOURCE) && zig build -Demit-lib-vt --prefix $(INSTALL)
@test -f $(INSTALL)/lib/libghostty-vt.a || { echo "ERROR: expected static lib at $(INSTALL)/lib/libghostty-vt.a"; exit 1; }
@echo ">> libghostty-vt installed under $(INSTALL)"
deps-build: $(INSTALL)/lib/libghostty-vt.a
clean-deps:
rm -rf $(SOURCE) $(INSTALL)
spike: deps
go build -o ./bin/spike ./cmd/spike
patterm: deps
go build -o ./bin/patterm ./cmd/patterm
test: deps
go test ./...

542
SPEC.md Normal file
View File

@@ -0,0 +1,542 @@
This is a spec for a terminal project I have.
I think we could probably use libghostty for the terminal emulation as it seems the go ecosystem is quite sparse.
# patterm — v1 Spec
*Working title: **patterm**. Used throughout this document.*
## 1. Overview
A terminal-based agent orchestration shell. The user opens patterm in a project directory (e.g. `~/Dev/foo`). patterm presents a multi-tab TUI where each top tab is a **session** — a long-running PTY launched from a user-defined **preset**. Presets come in two flavours: **agent presets** (e.g. claude, codex, opencode — vendor LLM CLIs with patterm's MCP wired up) and **process presets** (e.g. `bun run dev`, `vitest --watch` — raw commands with no MCP). Each session has a sidebar of **children**: more presets spawned by that session, again either agents or processes, all PTY-backed. The right rail also surfaces project-scoped **scratchpads** (markdown files) for human readability.
An MCP server, in-process, exposes tools that let orchestrator agents spawn and drive children, run processes, set timers, message peers, and read/write scratchpads. The orchestrator is a real LLM CLI driving another LLM CLI as if it were a human user — keystroke injection in, rendered-grid scraping out. The orchestrator fully owns the content of what it sends; patterm only handles the plumbing.
**Goal:** Let one SOTA agent orchestrate other agents of different types (claude → codex, codex → opencode, …) without subagent APIs, while keeping the whole thing steerable and observable by a human at any moment.
**Non-goal:** Hosting any LLM. patterm only manages CLIs the user already has installed. patterm also doesn't ship hard-coded knowledge of any specific vendor CLI — agent presets are user-editable JSON; the three common ones (claude, codex, opencode) ship as defaults.
---
## 2. Architecture and lifecycle
**Single foreground process. No daemon, no detach.**
The tool is one Go process that owns: the TUI, all PTYs, vt-emulated grids, session state, child state, scratchpad files, and an in-process MCP server. Killing the process kills everything inside it. There is no attach/detach, no project-keyed singleton, no socket-based reattachment.
**Lifecycle:**
1. User runs `patterm` in a project directory.
2. The process starts the TUI as a **blank canvas** — no sessions, no children, no scratchpad preview. Just the empty frame with the palette hint in the status line. The in-process MCP server initializes (bound to a per-PID unix socket for spawned children — see §10) and scratchpad metadata is loaded from disk, but nothing is rendered until the user opens a preset.
3. The user opens the palette (`Ctrl-K`), selects a preset, and the first session/process is launched. Subsequent sessions and children are spawned the same way (or by orchestrators via MCP).
4. On exit (Ctrl-D, `:quit`, terminal window close, SIGTERM, SIGHUP): the process sends SIGTERM to every child PTY with a short grace window, then SIGKILL, then exits. Scratchpads on disk are the only thing that survives.
**Multiple invocations:** Running `patterm` twice in the same project starts two independent processes. They share scratchpad files on disk but nothing else. If this turns out to be a footgun in practice, a per-project lockfile can be added later — out of scope for v1.
**Implications:** Closing the terminal window (or SSH dropping) ends the session and tears down every child. This is the deliberate trade — no orphan daemons, no socket discovery, no stale-state recovery, no multi-client coordination. The user's terminal window *is* the lifetime boundary.
---
## 3. Project state layout
Scratchpads (user data) live under `$XDG_DATA_HOME`; presets and config live under `$XDG_CONFIG_HOME`.
```
$XDG_DATA_HOME/patterm/
└── projects/
└── <project-key>/
├── meta.json # project path, last-opened, version
└── scratchpads/
├── notes.md
├── todos.md
└── <agent-written>.md
$XDG_CONFIG_HOME/patterm/
├── config.json # global settings (theme, default keymap, etc.)
└── presets/
├── agents/
│ ├── claude.json # ships as default
│ ├── codex.json # ships as default
│ ├── opencode.json # ships as default
│ └── <user-defined>.json
└── processes/
├── dev.json # e.g. { "name": "bun run dev", "argv": ["bun", "run", "dev"] }
├── test.json
└── <user-defined>.json
```
Both preset directories are scanned at startup; every file found becomes a palette entry ("Spawn agent: claude", "Run process: bun run dev", …). Presets are project-agnostic in v1 — the same set is available in every project. Per-project overrides can be added later.
Project key = `sha256(realpath(project_dir))[:16]`. Used only as a scratchpad directory name — there is no daemon to look up.
Internal MCP socket (for spawned children to talk to the running process): `$XDG_RUNTIME_DIR/patterm/<pid>.sock`, falling back to `/tmp/patterm-<pid>.sock` if `XDG_RUNTIME_DIR` is unset. Created on startup, removed on exit. Per-PID, not per-project — it is a private IPC channel, not a discovery point.
Scratchpads persist across runs. Sessions and child processes do not.
---
## 4. UI / Client
```
┌────────────────────────────────────────────────────────┬──────────────────┐
│ [codex-1] [codex-2] [claude-1] + │ Session tree │
├────────────────────────────────────────────────────────┤ ────── │
│ │ ▶ codex-1 │
│ │ │ │
│ │ ├─ ◉ claude-2 │
│ │ ├─ ◉ claude-3 │
│ (focused pane's PTY) │ ├─ ◉ claude-4 │
│ │ └─ ◉ bun-dev │
│ │ │
│ │ Scratchpads │
│ │ ────── │
│ │ todos.md │
│ │ notes.md │
│ │ api-plan.md │
│ │ │
│ │ ┌────────────┐ │
│ │ │ todos.md │ │
│ │ │ preview… │ │
│ │ └────────────┘ │
├────────────────────────────────────────────────────────┴──────────────────┤
│ [orchestrator driving] Ctrl-K command palette │
└───────────────────────────────────────────────────────────────────────────┘
```
- **Top tab bar:** one per top-level session. `+` opens the palette pre-filtered to "Spawn…" entries.
- **Main area:** the focused pane's PTY, rendered identically to viewing it in a regular terminal. The focused pane is either the orchestrator (root of the active session's tree) or one of its children, whichever the user last selected from the sidebar.
- **Right rail, top half — session tree:** the active session's process hierarchy, drawn as an indented tree with box-drawing connectors (`├─`, `└─`). The orchestrator is the root (`▶`); each child appears one level deeper with a status glyph (`◉` running, `✓` exited cleanly, `✗` errored). Selecting an entry (palette, arrow keys, or click) makes it the focused pane. v1 only has two levels because of the §8 two-level-tree rule, but the renderer should be tree-shaped from day one so a future depth bump doesn't require UI surgery.
- **Right rail, bottom half:** scratchpad list and a preview of the selected scratchpad.
- **Status line:** input-ownership toast ("orchestrator driving" / "you have control") on the left, palette hint on the right.
**Empty state:** Until the user spawns their first preset, the top tab bar, main area, and sidebar all sit empty with a centred hint ("Press Ctrl-K to spawn an agent or process"). No "default session" is created.
**Switching:** Clicking a top tab (or selecting one via the palette) switches the active session — the sidebar tree swaps to that session's hierarchy. Clicking a sidebar entry switches the focused pane within the current session.
**Command palette (v1 input model):**
Almost all application functions are driven through a single command palette opened with `Ctrl-K`. The palette is a fuzzy-searchable list of commands, scoped to whatever makes sense for the current focus. Two kinds of entries appear:
- **Built-in commands** — "Switch to session…", "Focus pane…", "Take input control", "Release control to orchestrator", "Open scratchpad…", "Kill child…", "Quit", etc.
- **Preset commands** — one entry per file under `$XDG_CONFIG_HOME/patterm/presets/`. Agent presets surface as "Spawn agent: codex" / "Spawn agent: claude" / …; process presets surface as "Run process: bun run dev" / "Run process: vitest" / …. The label comes from the preset's `name` field; the action is "launch this preset into a new pane."
Selecting a preset either launches it immediately (no required args) or opens a sub-palette for optional args — namely an **initial prompt** (agent presets only), which patterm injects into the spawned PTY's input after the agent is ready (§8). The orchestrator equivalent of this — `spawn_agent` / `spawn_process` MCP tools — uses the exact same machinery: pick a preset by name, optionally supply an initial prompt, patterm handles the rest.
Rationale: the keybinding surface for sessions + children + scratchpads + control transfer + spawning gets large fast. A palette lets us ship the full feature set without committing to a key map yet, and gives the user a discoverable index of every action. Dedicated keybindings can be layered on top later for the few actions a user does often enough to memorize — they should be configured by binding to palette command IDs, not by re-implementing the action.
Only two keybindings are reserved at the application level in v1:
| Action | Binding |
|---|---|
| Open command palette | `Ctrl-K` |
| Pass-through prefix (everything else after this goes to the focused PTY untouched, e.g. for nested tmux/Ctrl-K-using TUIs) | `Ctrl-K Ctrl-K` |
Everything else — session switching, child cycling, control transfer, quitting — lives in the palette for v1.
---
## 5. PTY layer
One PTY per session orchestrator and one per child. For each PTY the tool maintains:
- The underlying process (pid, status, exit code on death).
- A raw byte ring buffer (default 1 MiB) for stream-mode reads.
- A vt-emulated character grid representing current visible state.
- Alt-screen flag (whether the process is in alternate-buffer mode, i.e. a TUI).
- Last-write timestamp (used for the idle heuristic).
**Terminal emulator:** Go has limited options. Start with `vt10x` or a maintained fork. Budget real time — this is the load-bearing component for grid mode `read_output`. The emulator must handle: SGR colours (then strip them on read), cursor movement, alt-screen entry/exit, scroll regions, basic mouse passthrough where needed.
**Resize:** On startup and on SIGWINCH, the tool reads its own terminal dimensions, computes per-pane winsize (accounting for tab bar, sidebar, status line), and `ioctl(TIOCSWINSZ)` each PTY. Children get SIGWINCH automatically. One process, one viewport — no multi-client resize negotiation.
---
## 6. Input ownership
Each pane has an owner flag: `user` or `orchestrator`. A toast / status-line glyph reflects current owner.
- When the orchestrator spawns a child, that child defaults to orchestrator-owned.
- When the user focuses a pane and presses any key, ownership flips to `user`. The orchestrator can still write — bytes interleave. A warning toast appears: "Orchestrator is also driving this pane."
- The user explicitly returns ownership with the release key.
No locking. The user's call if they collide. The visual indicator is the only protection.
---
## 7. MCP tool surface
The tool embeds an MCP server in-process. Each spawned agent gets an MCP config injected at spawn time (see §10) pointing at a stdio proxy subcommand of the same binary, which forwards JSON-RPC over the per-PID unix socket to the running process. Tool calls carry an implicit caller identity (which session / which child) derived from the connection.
### Tools available to orchestrators only
#### `spawn_agent`
- **Args:** `preset` (string — name of an agent preset under `$XDG_CONFIG_HOME/patterm/presets/agents/`), `initial_prompt` (string), `name?` (display name, defaults to `<preset>-<n>`)
- **Behaviour:** Launches the agent preset in a new PTY as a child of the calling session. Wires MCP per the preset's injection strategy (§10). Waits for the preset's ready signal (default: 1s idle). Then types `initial_prompt` into the TUI input box and submits. patterm does not inject any other text — the caller's `initial_prompt` is the agent's first turn. If the caller wants the agent to know about the message-tag conventions (§8), tool availability, or its orchestrator role, the caller must say so in `initial_prompt`.
- **Returns:** `child_id`.
- **Error:** Returns an error if `preset` isn't a known agent preset. patterm has no built-in knowledge of vendor CLIs — everything is preset-driven.
#### `send_message_to`
- **Args:** `target` (child_id), `message` (string)
- **Behaviour:** Types `[orchestrator] <message>\n` into the target child's PTY.
- **Returns:** `ok`.
#### `request_human_attention`
- **Args:** `child_id`, `reason` (string)
- **Behaviour:** Surfaces a notification in the TUI, blinks the sidebar entry for the child, optionally auto-focuses if the user setting allows it. Used by orchestrator when it wants to punt a decision (e.g. ambiguous permission prompt) to the human.
- **Returns:** `ok`.
### Tools available to all agents
#### `spawn_process`
- **Args:** One of:
- `preset` (string — name of a process preset under `$XDG_CONFIG_HOME/patterm/presets/processes/`), plus optional `working_dir?` / `env?` overrides; **or**
- `argv` (array of strings — freeform launch), with optional `working_dir?`, `env?`, and `shell?` (default `false`; when `true`, `argv` is interpreted as `["sh", "-lc", argv[0]]`-style).
- **Behaviour:** Launches the command in a new PTY, attached as a child of the calling agent's session. Presets are the preferred path; freeform `argv` is the escape hatch for one-offs the user hasn't pre-configured. No MCP injection (process children aren't agents).
- **Returns:** `child_id`.
#### `read_output`
- **Args:** `child_id`, `mode` (`grid` | `stream`), `since_offset?` (stream mode only)
- **Behaviour:**
- `grid` mode: returns the current rendered visible grid as plain text, ANSI stripped, with best-effort trimming of detectable vendor chrome (top banner, bottom input box, status line) per agent-type heuristics. Use for TUI children.
- `stream` mode: returns raw byte content from `since_offset` to current write head, ANSI stripped. Use for line-mode processes.
- **Returns:** `{ content: string, new_offset: int, mode: "grid" | "stream" }`.
- **Note in tool description (visible to the calling agent):** "The grid result is the entire visible pane. You are responsible for locating the response to your last prompt within it."
#### `send_input`
- **Args:** `child_id`, `input` (string), `append_newline?` (default `true`)
- **Behaviour:** Writes bytes to the child PTY's stdin. Used both for free-form input and for single-key confirmations (`y`, `n`).
- **Returns:** `ok`.
#### `kill`
- **Args:** `child_id`, `signal?` (default `SIGTERM`)
- **Returns:** `ok`.
#### `wait_for_pattern`
- **Args:** `child_id`, `pattern` (regex), `timeout_seconds`
- **Behaviour:** Blocks the calling agent until the rendered grid matches the regex or the timeout expires. Polls the grid at ~50ms intervals.
- **Returns:** `{ matched: bool, snippet?: string }`.
#### `timer_wait`
- **Args:** `seconds`, `label?` (default auto-generated)
- **Behaviour:** Returns immediately with a `timer_id`. After `seconds`, the tool injects `[system] Your timer [<label>] has completed.\n` into the calling agent's pane.
- **Returns:** `{ timer_id: string }`.
#### `list_children`
- **Args:** none
- **Returns:** Array of `{ child_id, name, type, status, exit_code? }` for the calling agent's session.
#### `scratchpad_list`
- **Returns:** Array of `{ name, size, modified_at }`.
#### `scratchpad_read`
- **Args:** `name`
- **Returns:** `{ content: string }`.
#### `scratchpad_write`
- **Args:** `name`, `content` (full replacement)
- **Returns:** `ok`.
#### `scratchpad_append`
- **Args:** `name`, `content`
- **Returns:** `ok`.
---
## 8. Conversation protocol
patterm does **not** inject any framing or system-prompt text into spawned agents. Whatever an agent sees in its input is exactly what the user typed or what an orchestrator chose to send. The orchestrator (or the human launching it) is responsible for telling a spawned agent what its role is, what tools it has, and what conventions to expect.
That said, when patterm relays messages programmatically between agents or surfaces lifecycle events, it tags them so the receiving agent can distinguish sources. These tags are the patterm convention; agents will encounter them in their input and are expected to recognize them from context (or because their parent explained them in the initial prompt).
- `[orchestrator] <msg>` — prepended when `send_message_to` delivers a message from a parent to a child.
- `[sub-agent:<name>] <msg>` — prepended when `report_to_parent` delivers a message from a child to its parent.
- `[system] <msg>` — patterm itself (timer fires, child exited, etc.).
- Direct user typing is **not** prefixed. The user sees the pane and types normally; the agent receives the keystrokes as-is.
No "ready" handshake. patterm treats the agent as ready once its PTY hits the preset's `ready_signal` (default: 1s idle after launch — see §10). The very first thing the agent receives after that point is whatever the caller passed as `initial_prompt`.
Two-level tree only. Sub-agents cannot call `spawn_agent`.
---
## 9. Permissions flow
Sub-agents are launched with vendor permissions **on** — the orchestrator drives their confirmation prompts.
Loop:
1. Orchestrator sends a message to a sub-agent via `send_message_to`.
2. Sub-agent runs, eventually hits a tool-use confirmation in its TUI ("Allow Bash(rm -rf foo)? [y/N]").
3. Sub-agent goes idle (cursor stops animating, no byte writes for 1s).
4. Orchestrator's loop calls `read_output(child_id, mode="grid")`, sees the prompt, decides, and calls `send_input(child_id, "y")` or `"n"`.
5. If the orchestrator can't safely decide, it calls `request_human_attention(child_id, "Sub-agent wants to run X, looks destructive, need your call")`. The orchestrator then waits (using `wait_for_pattern` or repeated reads) until the prompt is no longer on screen.
Risks acknowledged: the orchestrator's reading of the prompt is a vision/parsing problem on rendered text. We trust a SOTA model to handle this correctly. The `request_human_attention` punt is the safety valve.
---
## 10. Presets
Presets are user-editable JSON files that describe how to launch something. patterm itself has no hard-coded agent or process types — every spawnable thing is a preset. Two flavours:
### Agent presets
`$XDG_CONFIG_HOME/patterm/presets/agents/<name>.json`. Launches a vendor LLM CLI with MCP wired up and the conversation-protocol addendum injected.
| Field | Purpose |
|---|---|
| `name` | Display name shown in the palette (e.g. "claude", "codex haiku", "opencode-experimental") |
| `argv` | Full launch argv (e.g. `["claude"]`, `["codex", "--no-tui-banner"]`) |
| `env` | Env vars to set (merged over inherited env) |
| `working_dir` | Defaults to the project root |
| `mcp_injection` | How to point this CLI at patterm's stdio proxy. One of: `{ "kind": "flag", "flag": "--mcp-config", "config_path": "..." }`, `{ "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" }`, `{ "kind": "env_var", "var": "MCP_CONFIG_PATH" }` |
| `ready_signal` | How to detect the TUI is ready (default: 1s idle after launch). Override per-CLI if needed. |
| `chrome_trim_hints` | Optional regexes / row ranges for stripping vendor chrome in grid reads |
Default presets shipped: `claude`, `codex`, `opencode`. Authoring these is per-vendor research — each CLI has its own MCP config conventions, ready states, and TUI chrome. Users can copy and edit them, or add new ones (e.g. a second `claude` preset that launches with a specific model or system prompt file).
MCP config flow: at startup, for each agent preset, patterm renders a small JSON pointing at its own `mcp-stdio` proxy subcommand (`patterm mcp-stdio --socket <pid-sock> --identity <token>`) into a per-preset temp file. The launch then uses the preset's `mcp_injection` strategy to hand that path to the CLI. The user's global vendor config is never mutated.
### Process presets
`$XDG_CONFIG_HOME/patterm/presets/processes/<name>.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt.
| Field | Purpose |
|---|---|
| `name` | Display name shown in the palette (e.g. "bun run dev") |
| `argv` | Launch argv (e.g. `["bun", "run", "dev"]`) |
| `shell` | If `true`, argv is interpreted via `sh -lc`. Default `false`. |
| `env` | Env vars to set |
| `working_dir` | Defaults to the project root |
Process presets are intentionally thin: they're shortcuts for commands the user runs often. Anything more exotic — pipelines, redirections — uses `shell: true`, or the orchestrator can call `spawn_process` with freeform argv.
---
## 11. Done-signal heuristic
A pane is considered "idle" when no bytes have been written to its PTY's master end for **1000 ms**.
Rationale: every supported vendor TUI animates a spinner while busy (during LLM streaming and during tool execution). A genuinely idle pane stops animating.
Caveats and mitigations:
- LLM provider hiccups can cause >1s gaps mid-stream. Per-agent tuning of the idle threshold is allowed in the preset.
- Orchestrators should treat idle as a signal to *read*, not as a guarantee of completion. If the read returns something ambiguous, they can `wait_for_pattern` with a known terminal marker (e.g. the agent's input prompt) for stronger evidence.
- The tool exposes idle state via `list_children` so orchestrators don't need to poll byte streams directly.
---
## 12. Failure modes
| Failure | Behaviour |
|---|---|
| Sub-agent process exits unexpectedly | Sidebar marks child as exited, exit code preserved. Orchestrator's next `read_output` returns final grid + exit metadata. |
| Vendor CLI hangs without exiting | Looks idle. Orchestrator must use `wait_for_pattern` or `request_human_attention` to escape. |
| Tool process crashes | All PTYs are children of the tool's process group; OS cleans them up (process-group SIGHUP on terminal close, PTY master close, parent-death signal on Linux). On macOS treat cleanup as best-effort; scratchpads on disk survive. |
| User closes the terminal window / SSH drops | Process receives SIGHUP, cascades SIGTERM → SIGKILL to every child, exits. Everything inside the tool dies with it. This is the intended model. |
| Disk full on scratchpad write | Tool returns error to caller. |
| LLM provider network blip | Pane idles, may trigger false "done" — orchestrator should sanity-check responses. |
| User kills the orchestrator pane | Tool detects PTY close, cascades SIGTERM to that session's children. |
| Concurrent input | Bytes interleave on PTY stdin. Toast warns. User's call. |
| Vt emulator bug on exotic ANSI | Grid rendering corrupts for that pane. Orchestrator's read will be noisy; degrade gracefully, don't crash. |
---
## 13. Out of scope for v1
- Cross-project orchestration.
- Sub-agents spawning sub-agents (trees deeper than 2).
- Daemonized / detachable sessions surviving the terminal window. The tool is intentionally bound to the user's foreground process.
- Multi-client attach to a single session.
- Native ACP support (PTY scraping only).
- Hosting any LLM internally.
- Auth beyond OS-level file permissions on the IPC socket and state dir.
- Web / API control surface.
- Recording / replay of sessions.
---
## 14. Open questions
- **Vt emulator library.** Resolved in the closing note — `libghostty-vt` is the bet, with `vt10x` / `charmbracelet/x/vt` as fallback only.
- **MCP transport.** Resolved — in-process MCP core with a `mcp-stdio` proxy subcommand for spawned children (see §7 and §10). Streamable HTTP can be added later.
- **Scratchpad concurrency.** Two agents writing the same scratchpad: last-write-wins with a revision token (see addendum item 7 in the closing note). Agents are expected to coordinate.
- **Default presets that ship in the box.** claude / codex / opencode is the working set; trimming to two for the first cut is fine since presets are user-editable anyway.
- **Per-project preset overrides.** v1 has a single global preset directory. Whether `./.patterm/presets/` should override per-project is a v2 question.
---
## 15. Suggested build order
1. Single-process skeleton: TUI bootstraps, owns the terminal, handles SIGWINCH / SIGHUP / SIGTERM, exits cleanly.
2. Single PTY per session + vt emulator + tab bar UI + basic input/render.
3. Multi-session, multi-child (sidebar) with raw process spawning, process groups, kill cascade on exit (no MCP yet).
4. In-process MCP server + `mcp-stdio` proxy subcommand + per-PID unix socket + `spawn_process` / `read_output` / `send_input` / `kill` / `wait_for_pattern`.
5. `spawn_agent` preset for one agent (probably claude), conversation tag conventions, `initial_prompt` injection (typed into the TUI input after ready).
6. Scratchpads, `timer_wait`, `request_human_attention`, `send_message_to`, `report_to_parent`.
7. Second and third agent presets, chrome-trim heuristics.
8. Polish: command palette, status indicators, error UX.
---
Yes — use `libghostty-vt` for the terminal emulation layer. Not full Ghostty, and not as a UI renderer. Use it as a headless VT state machine inside the tool process, wrapped behind your own Go interface.
`libghostty-vt` is basically aimed at exactly your load-bearing problem: it is a C library extracted from Ghostty that handles VT parsing, terminal state, scrollback, line wrapping, resize reflow, input event encoding, and related terminal internals. The current docs also warn that the API is still unstable, so this should be a pinned dependency, not something you casually track at HEAD. ([libghostty.tip.ghostty.org][1])
The right move is:
```go
type Emulator interface {
WritePTYOutput([]byte)
Resize(cols, rows uint16)
PlainText() string
Cell(x, y int) Cell
Cursor() Cursor
ActiveScreen() Screen
}
```
Then implement `GhosttyEmulator` behind that. Keep `vt10x` or `charmbracelet/x/vt` as experimental/fallback only. `vt10x` is pure Go and convenient, but its own package docs describe it as “in development”; Charms `x` repo is explicitly experimental with no backwards-compatibility promise. For this project, terminal fidelity is not a nice-to-have; it is the product. ([Go Packages][2])
The best part: `libghostty-vt` already has formatter support for producing plain text from the active screen, which maps cleanly to your `read_output(mode="grid")`; it also exposes key and mouse encoding, which matters once you stop only typing ASCII strings and start needing arrows, Ctrl-C, Tab, Escape, mouse passthrough, and Kitty keyboard protocol support. ([libghostty.tip.ghostty.org][3])
The catch: cgo/build packaging becomes real. Pin a commit, vendor or checksum the library, and put all C ABI calls in one internal package. Do not scatter cgo across the codebase.
Big spec changes Id make before building:
First, change MCP transport strategy. Implement the in-process MCP core once, then expose it via a tiny stdio proxy subcommand:
```sh
patterm mcp-stdio --socket "$SOCK" --identity "$TOKEN"
```
Each spawned agent gets an MCP config pointing at that command. The vendor CLI thinks it is launching a normal stdio MCP server; the proxy forwards JSON-RPC to the running tool process over its per-PID Unix socket. This avoids relying on every CLI supporting HTTP over Unix sockets, gives you clean per-agent identity, and keeps the tool process as the single owner of state.
Still support Streamable HTTP later, but stdio-proxy-first is more robust for local CLIs. MCP currently defines stdio and Streamable HTTP as standard transports, and Claude Code, Codex, and OpenCode all expose MCP configuration paths that can work with local or HTTP-style servers. ([Model Context Protocol][4])
Second, remove the generic `MCP_CONFIG_PATH` assumption. Each preset needs real vendor-specific MCP config handling. Claude Code supports `--mcp-config` and `--strict-mcp-config`. ([Claude][5]) Codex config uses `~/.codex/config.toml` / project `.codex/config.toml`, with `mcp_servers.<id>.command` for stdio and `mcp_servers.<id>.url` for HTTP. ([OpenAI Developers][6]) OpenCode exposes MCP through its `mcp` config option and `opencode mcp add`, so that preset needs its own path too. ([OpenCode][7])
Third, add a child-to-parent MCP tool. Your conversation protocol mentions `[sub-agent:<name>]` messages reporting back, but the tool surface does not currently include a way for a sub-agent to send one. Add:
```text
report_to_parent(message: string) -> ok
```
Then the tool injects:
```text
[sub-agent:codex-2] <message>
```
into the parent orchestrator pane. Without this, the orchestrator has to scrape the child forever, which is workable but worse.
Fourth, change `spawn_process(command: string)` to an argv form:
```json
{
"argv": ["bun", "run", "dev"],
"working_dir": ".",
"env": {},
"shell": false
}
```
Let agents explicitly request shell mode:
```json
{
"argv": ["sh", "-lc", "bun run dev | tee /tmp/dev.log"],
"shell": true
}
```
A raw command string is quoting hell and makes policy inspection harder.
Fifth, make permission handling more conservative. The orchestrator reading a rendered confirmation prompt is useful, but it is not a safety boundary. A malicious repo or child process can print misleading prompt-like text. Default policy should be: auto-answer only boring, allowlisted prompts; punt writes, deletes, network exfiltration, credential access, `sudo`, package install scripts, and broad shell commands to the human. OpenCodes own docs say operations are allowed by default unless permissions are configured, so per-agent recipe permissions need to be deliberate rather than assumed safe. ([OpenCode][7])
Sixth, child cleanup on tool exit must be real. There is no daemon to keep PTYs alive — but the OS will not magically reap children either. Put every spawned PTY in the tool's process group (or a dedicated sub-group), set Linux `PR_SET_PDEATHSIG` on children, close PTY masters on exit, and install a SIGHUP/SIGTERM handler that runs the SIGTERM→grace→SIGKILL cascade before the process actually exits. On macOS, parent-death signals don't exist; rely on process-group SIGHUP and PTY master close, and treat any straggler cleanup as best-effort. A stale-process sweep on next startup is unnecessary now that there is no daemon to outlive its children.
Seventh, revise `send_input`. Text plus `append_newline` is too weak. You need:
```json
{
"kind": "text" | "paste" | "key",
"text": "...",
"key": "enter|tab|escape|ctrl-c|left|right|up|down",
"submit": true
}
```
Use bracketed paste for multi-line prompt injection where the target TUI supports it. Otherwise multi-line prompts can accidentally submit partial content.
Eighth, expose more metadata in `read_output`. Return row numbers, active screen, cursor position, idle state, process status, and maybe a `screen_version`.
```json
{
"content": "...",
"mode": "grid",
"active_screen": "alternate",
"rows": 38,
"cols": 120,
"cursor": {"x": 4, "y": 37},
"idle_ms": 1420,
"screen_version": 9182,
"status": "running"
}
```
Models are better at parsing when you give them stable structure.
For `libghostty-vt`, the implementation detail that matters most is effects. The docs say VT processing handles terminal state by default, but side-effect sequences such as bell, title changes, device queries, and write-back responses need configured callbacks; those callbacks are synchronous and should not block. Wire at least `WRITE_PTY`, bell, title, size/query responses, and active-screen tracking early. ([libghostty.tip.ghostty.org][8])
Recommended revised build order:
1. PTY + `libghostty-vt` spike before any UI work. Spawn `bash`, `vim`, `htop`, Claude/Codex/OpenCode if installed, feed output into Ghostty, dump plain grid. This either validates the core bet or kills it early.
2. Single-process TUI with one PTY session. SIGWINCH-driven resize from the tool's own terminal. No MCP yet.
3. Raw child process spawning, sidebar, process groups, kill cascade on exit/SIGHUP, idle detection.
4. MCP stdio proxy subcommand and core tools: `spawn_process`, `read_output`, `send_input`, `kill`, `list_children`.
5. One orchestrator preset, probably Claude first because it has useful CLI flags for MCP config. Use `--mcp-config` and `--strict-mcp-config` so the user's global Claude config isn't mutated. ([Claude][5])
6. `spawn_agent`, `report_to_parent`, `send_message_to`, and timer injection.
7. Scratchpads with revision IDs. Last-write-wins is okay for v1, but return a revision so agents can avoid blind overwrites:
```json
scratchpad_read -> { "content": "...", "revision": "abc123" }
scratchpad_write -> { "content": "...", "expected_revision": "abc123" }
```
8. Second and third recipes. Keep recipe files declarative, but expect custom Go code for each vendor.
9. Chrome trimming heuristics and golden tests using recorded VT byte streams from each supported CLI.
One more practical point: put scratchpads under XDG data, not config. Something like:
```text
$XDG_DATA_HOME/patterm/projects/<key>/scratchpads/
```
Keep spawn recipes/config under:
```text
$XDG_CONFIG_HOME/patterm/
```
Scratchpads are user data, not configuration. Not fatal, but fixing it now avoids awkward migration later.
Overall: the concept is buildable, but the hard parts are not MCP or the TUI chrome. The hard parts are terminal fidelity, process lifecycle, vendor recipe drift, and permission safety. `libghostty-vt` is the right core bet, provided you isolate it behind an interface and treat its unstable API as a vendored implementation detail.
[1]: https://libghostty.tip.ghostty.org/ "libghostty: libghostty-vt - Virtual Terminal Emulator Library"
[2]: https://pkg.go.dev/github.com/micro-editor/terminal "terminal package - github.com/micro-editor/terminal - Go Packages"
[3]: https://libghostty.tip.ghostty.org/group__formatter.html "libghostty: Formatter"
[4]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports "Transports - Model Context Protocol"
[5]: https://code.claude.com/docs/en/cli-reference "CLI reference - Claude Code Docs"
[6]: https://developers.openai.com/codex/config-reference "Configuration Reference Codex | OpenAI Developers"
[7]: https://opencode.ai/docs/config/ "Config | OpenCode"
[8]: https://libghostty.tip.ghostty.org/group__terminal.html "libghostty: Terminal"

128
SPIKE-REPORT.md Normal file
View File

@@ -0,0 +1,128 @@
# Milestone 1 Spike Report — libghostty-vt
**Date:** 2026-05-12
**Pin:** `ghostty-org/ghostty@b0f8276658fbcc75318d2125d40146074a3fc505` (main, post-v1.3.1)
**Spike binary:** `./bin/spike` (built against the static `libghostty-vt.a`)
## Verdict
**The libghostty-vt bet is validated.** Proceed to milestone 2 (daemon/client
singleton + single PTY).
Every target except `htop` (not installed locally, deferred) rendered correctly.
The libghostty-vt-emulated grid matched the live host-terminal display cell-for-cell
across plain stream output, interactive bash, alt-screen TUIs (vim), and the three
agent CLIs that are the actual point of the project (claude, opencode, codex).
## Target matrix
| # | Target | Screen | Verdict | Grid log | PTY bytes |
|---|---|---|---|---|---|
| 1 | `sh -c 'echo hello; sleep 1'` | primary | ✅ | `spike-201533.grid.log` | `spike-201533.bytes` |
| 2 | `bash -i` | primary | ✅ | `spike-201606.grid.log` | `spike-201606.bytes` |
| 3 | `vim SPEC.md` | alt → primary | ✅ | `spike-201707.grid.log` | `spike-201707.bytes` |
| 4 | `htop` | — | ⊘ deferred | not run | not run |
| 5 | `claude` | primary | ✅ | `spike-201848.grid.log` | `spike-201848.bytes` |
| 6 | `opencode` | alt | ✅ | `spike-202380.grid.log` | `spike-202380.bytes` |
| 7 | `codex` | primary | ✅ | `spike-202614.grid.log` | `spike-202614.bytes` |
### Notable observations per target
- **vim**: alt-screen entry/exit tracked correctly; on `:q` returned to `primary` with
empty grid and cursor at `(0,0)`. The formatter's `unwrap=true` setting reassembled
soft-wrapped paragraphs from SPEC.md into long single-line paragraphs — this is
exactly the shape an orchestrator agent wants when reading sub-agent output.
- **claude** and **codex** render on the **primary** screen, not alt-screen. They draw
their own cursor (`visible=false`). Chrome heuristics in milestone 7 will need to be
per-agent, not per-screen-buffer.
- **opencode** uses alt-screen and a heavy bar-art logo that visually looks "broken"
on first glance — it isn't. The grid dump confirms cell-perfect parsing.
- **bash**: cooked PTY line discipline is doing its job — `echo test``test`
`[harry@…]$ exit``exit` all sequence correctly in the final dump, cursor at (0,4).
## API surface used
From `include/ghostty/vt/`:
- `ghostty_terminal_new` / `ghostty_terminal_free`
- `ghostty_terminal_vt_write` — feed PTY bytes
- `ghostty_terminal_resize(cols, rows, 0, 0)` — pixel dims ignored for headless
- `ghostty_terminal_set` for: `USERDATA`, `WRITE_PTY`, `DEVICE_ATTRIBUTES`,
`XTVERSION`, `ENQUIRY`
- `ghostty_terminal_get` for: `CURSOR_X`, `CURSOR_Y`, `CURSOR_VISIBLE`, `ACTIVE_SCREEN`
- `ghostty_formatter_terminal_new` + `ghostty_formatter_format_alloc` +
`ghostty_formatter_free` + `ghostty_free` (allocator-aware)
- Format options: `FORMAT_PLAIN`, `unwrap=true`, `trim=true`
Everything we needed was present and stable enough to use behind a Go interface.
## Bugs found and fixed during the spike
1. **v1.3.1 tag didn't yet expose `terminal.h` / `formatter.h`.** Bumped the pin
forward to a commit on `main` where the full API is published.
2. **Recursive-mutex deadlock** between `Emulator.Write` and the `WRITE_PTY` cgo
callback (Write held `e.mu`, callback re-entered Go and tried to take `e.mu`
again). Switched the callback field to `atomic.Pointer`.
3. **Vim hung on startup.** Missing `DEVICE_ATTRIBUTES` callback meant DA1 queries
(`CSI c`) were silently ignored; vim waited forever. Now we respond with a
constant VT220-class identity (conformance 62, no features). Added stub
`XTVERSION` and `ENQUIRY` responders for the same reason.
4. **Raw-mode stderr produced ragged output**, with subsequent lines starting
mid-line. After `term.MakeRaw`, `OPOST` is off, so `\n` doesn't generate a CR.
Changed all spike stderr writes to `\r\n`.
5. **Grid dumps to stderr visually corrupted alt-screen TUIs.** The libghostty-vt
grid was always correct; the host terminal display was getting smashed because
the spike was writing 40+ lines onto a display owned by the TUI. Default sink
is now `spike-<pid>.grid.log`; user tails it from another terminal.
6. **Idle-dump breadcrumbs were chatty** — fired once per ~1 s of typing. Now only
hotkey dumps emit a breadcrumb, and only when the child is on the primary screen.
## Resolved open questions from the plan
-`ghostty_terminal_resize` exists. Takes pixel dimensions too; we pass `0, 0`.
- ✅ Build system is Zig (≥0.15.2). Downloaded the official tarball into
`.zig-cache/` and the `Makefile` `make deps` target produced static + shared libs
on first try. Pacman's zig 0.16.0 was not tested; not needed.
- ✅ Default `WRITE_PTY` callback is sufficient for DECRQM responses, but `DA1`,
`XTVERSION`, `ENQ` need their own handlers or vim and friends hang. Wired up
constant responders for all three.
- ✅ Active vs alternate screen tracking via `GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN`
is reliable across all three agent CLIs.
- ◻ Formatter cost per dump: each `PlainText()` call allocates and frees a buffer.
Acceptable for the spike. **Action for daemon era:** cache one formatter handle
per emulator instead of recreating on every dump.
## Risks / follow-ups for milestone 2
1. **Per-agent chrome trimming** (`SPEC.md §10`): claude and codex render on
primary screen, so we can't just "skip the alt-screen". Chrome-trim heuristics
will need to identify banner/input-box regions by content, not by screen buffer.
The `.bytes` recordings in this run are good fixtures for that work.
2. **Resize timing.** Spike resizes both PTY and emulator on SIGWINCH. Daemon will
have one emulator per pane and the spec's "primary-client-wins" policy must be
enforced before any UI work.
3. **Reading-back the spike report after the run.** The matrix script's stderr
capture (`2> >(tee -a "$REPORT" >&2)`) interleaved per-case lines awkwardly
(visible in `spike-report-20260512T154705.txt`). Cosmetic; doesn't affect
evaluation.
## Reproducing
```sh
# One-time
make deps # zig builds libghostty-vt.a
make spike # build ./bin/spike
# Single target
./bin/spike -- claude # then Ctrl-] to dump
tail -f spike-<pid>.grid.log # in another terminal
# Full matrix
./cmd/spike/testdata/run-matrix.sh
```
## Decision
Proceed with libghostty-vt as the headless VT for milestone 2. Keep the cgo wrapper
behind `internal/vt.Emulator` so the fallback (`charmbracelet/x/vt`, `vt10x`) stays
swappable, but no longer treat it as a real risk for v1.

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

@@ -0,0 +1,79 @@
// patterm is a terminal-based agent orchestration shell. SPEC §2: one
// foreground process owns the TUI, every PTY, and the in-process MCP
// server. Closing the terminal window ends the session.
//
// patterm run in $PWD
// patterm --project <dir> run in <dir>
// patterm mcp-stdio --socket S --identity I
// internal: stdio MCP proxy spawned for
// children, forwards JSON-RPC over S
package main
import (
"context"
"flag"
"fmt"
"os"
"github.com/harrybrwn/patterm/internal/app"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/projectkey"
)
func main() {
// The mcp-stdio subcommand is a separate top-level mode: when an
// agent CLI launches `patterm mcp-stdio --socket ...`, the same
// binary forwards JSON-RPC to the running process over the per-PID
// socket. SPEC §10.
if len(os.Args) >= 2 && os.Args[1] == "mcp-stdio" {
os.Args = append(os.Args[:1], os.Args[2:]...)
runMCPProxy()
return
}
var projectDir = flag.String("project", "", "project directory (default $PWD)")
flag.Parse()
cwd, err := os.Getwd()
if err != nil {
die("getwd: %v", err)
}
if *projectDir != "" {
cwd = *projectDir
}
key, err := projectkey.Key(cwd)
if err != nil {
die("project key: %v", err)
}
if err := os.Chdir(cwd); err != nil {
die("chdir %s: %v", cwd, err)
}
ctx := context.Background()
if err := app.Run(ctx, app.Options{
ProjectDir: cwd,
ProjectKey: key,
}); err != nil {
die("%v", err)
}
}
func runMCPProxy() {
var (
socket = flag.String("socket", "", "path to the running patterm process's MCP socket")
identity = flag.String("identity", "", "per-child identity token")
)
flag.Parse()
if *socket == "" || *identity == "" {
die("mcp-stdio: --socket and --identity are required")
}
if err := mcp.RunStdioProxy(*socket, *identity); err != nil {
die("mcp-stdio: %v", err)
}
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "patterm: "+format+"\n", args...)
os.Exit(1)
}

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

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

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

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

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module github.com/harrybrwn/patterm
go 1.26.3
require (
github.com/creack/pty v1.1.24
golang.org/x/term v0.43.0
)
require golang.org/x/sys v0.44.0 // indirect

6
go.sum Normal file
View File

@@ -0,0 +1,6 @@
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=

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)

1
third_party/libghostty-vt/COMMIT vendored Normal file
View File

@@ -0,0 +1 @@
b0f8276658fbcc75318d2125d40146074a3fc505

67
third_party/libghostty-vt/README.md vendored Normal file
View File

@@ -0,0 +1,67 @@
# Vendored libghostty-vt
This directory holds a pinned checkout of [libghostty-vt](https://libghostty.tip.ghostty.org/),
the headless VT emulator extracted from [Ghostty](https://github.com/ghostty-org/ghostty).
## Pin
- Repo: <https://github.com/ghostty-org/ghostty>
- Branch: `main`
- Commit: see `./COMMIT` — pinned to a specific SHA, not a tag.
We initially tried tag `v1.3.1` (`332b2aefc6...`) but the `terminal.h` and `formatter.h`
headers we need had not yet been added to the public API at that tag. The pin is to a
later commit on `main` where the full libghostty-vt surface (terminal + formatter +
encoders) is in `include/ghostty/vt/`.
The upstream API is explicitly unstable. Do not bump the pin casually — re-run the spike's
test matrix when you do.
## Layout
This directory is empty after `git clone` of `patterm`. The vendored source is fetched
on demand by the project's `Makefile`:
```
make deps # clones the pinned ghostty source into ./source/
# builds it via zig and installs into ./install/
```
After `make deps` the layout is:
```
third_party/libghostty-vt/
├── COMMIT # the pinned SHA, source of truth
├── README.md
├── source/ # shallow clone of ghostty-org/ghostty at $(cat COMMIT)
└── install/ # build output (consumed by cgo)
├── include/ghostty/...
├── lib/libghostty-vt.a
└── lib/libghostty-vt.so
```
`source/` and `install/` are gitignored.
## Build prerequisites
- `zig` ≥ 0.15.2 on `$PATH`. Arch Linux currently ships 0.16.0, which is newer than the
declared `minimum_zig_version` in upstream `build.zig.zon`. If zig refuses to build
with the installed version, install the matching toolchain via
[zigup](https://github.com/marler8997/zigup) or your distro's archive.
- A C toolchain for cgo (`gcc` or `clang`).
- `git` and `curl`.
## What we use from this library
The spike (`cmd/spike`) only touches a small surface:
- `ghostty_terminal_new` / `ghostty_terminal_free`
- `ghostty_terminal_vt_write` (feed PTY bytes in)
- `ghostty_terminal_resize` (on SIGWINCH)
- `ghostty_terminal_set` to install a `WRITE_PTY` callback (DA queries etc.)
- `ghostty_terminal_get` for `ACTIVE_SCREEN`, `CURSOR_X`, `CURSOR_Y`, `CURSOR_VISIBLE`
- `ghostty_formatter_terminal_new` + `ghostty_formatter_format_alloc` with
`GHOSTTY_FORMATTER_FORMAT_PLAIN`, `unwrap=true`, `trim=true`
Everything else (Kitty graphics, OSC parsing, render state, key/mouse encoders) is
deferred to later milestones.