2 Commits

Author SHA1 Message Date
45263d59f8 aggressive token saving attempts 2026-05-29 14:23:09 +01:00
51aac9f447 Reduce MCP token usage 2026-05-29 13:16:05 +01:00
42 changed files with 1082 additions and 4396 deletions

View File

@@ -7,48 +7,22 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `patterm daemon`, `patterm daemon stop`, and `patterm ls` now expose
a local unix-socket daemon lifecycle for the daemon/client split.
- The local daemon protocol now supports attach, explicit detach,
project listing, focused-pane snapshots, pane chunks, resize/focus
updates, and daemon-owned command spawn requests while keeping child
processes alive after a client disconnects.
- The default `patterm [dir]` startup now auto-starts the local daemon
on demand and attaches a thin terminal client over the unix-socket
transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy
single-process path available as an escape hatch.
- `patterm daemon --listen HOST:PORT` can now opt into a TCP listener
for remote human clients, with the unix socket still enabled for
local clients.
- `patterm connect --host HOST:PORT [--token TOKEN]` attaches the thin
client to a remote daemon over the same transport protocol.
- TCP attaches now require a lightweight bearer token stored under
`$XDG_DATA_HOME/patterm/clients/token`; local unix-socket attaches
remain exempt and rely on socket file permissions.
- The daemon now tracks a display owner per pane so a second client
viewing the same pane does not resize the underlying PTY/emulator;
ownership is released on detach and the next focuser can claim and
resize the pane.
- patterm can now keep multiple local projects loaded in one loopback
daemon core, with command-palette entries to switch the current
client view or open another project without tearing down processes
in the previous project.
- The status line now shows the current project name when multiple
projects are loaded, and the MCP startup greeting includes
`project_key` for diagnostics and future daemon routing.
- MCP clients can now call `scratchpad_delete` with a scratchpad name
to remove a shared project scratchpad.
### Changed
- The tab bar now shows each visible agent tab's own summary instead
of only rendering the focused tab's summary.
- Grid-mode `get_process_output` now returns whitespace-normalized
text to avoid sending padded terminal rows and repeated blank lines
over MCP.
- `get_process_output` now returns aggressively canonical terminal text
by default, removing ANSI/control noise, decorative borders, duplicate
status churn, and volatile progress/timer fragments; raw PTY bytes are
opt-in with `raw:true`.
- MCP responses now use slimmer defaults: tool-call JSON is no longer
duplicated into text content, large output and scratchpad reads are
capped with truncation metadata, and `whoami` / `get_project_status`
only include full tool lists when `include_tools` is requested.
### Fixed
- MCP scratchpad tools now route through the caller's project instead
of always using the daemon registry's default project.
- Injected agent input now sends the submit Enter as a separated,
settled keystroke so messages reliably submit instead of sometimes
sitting unsent in the composer.

View File

@@ -0,0 +1 @@
- [ ] Pasting into codex is no longer clean, it sends loads of messages rather than one clean paste.

View File

@@ -14,9 +14,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
@@ -29,7 +27,6 @@ import (
"github.com/hjbdev/patterm/internal/app"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/projectkey"
"github.com/hjbdev/patterm/internal/protocol"
)
// version is overridden at build time via `-ldflags "-X main.version=..."`.
@@ -51,25 +48,10 @@ func main() {
runDebugHarness()
return
}
if len(os.Args) >= 2 && os.Args[1] == "daemon" {
os.Args = append(os.Args[:1], os.Args[2:]...)
runDaemonCommand()
return
}
if len(os.Args) >= 2 && os.Args[1] == "connect" {
os.Args = append(os.Args[:1], os.Args[2:]...)
runConnectCommand()
return
}
if len(os.Args) >= 2 && os.Args[1] == "ls" {
runDaemonList()
return
}
var (
projectDir = flag.String("project", "", "project directory (default $PWD)")
showVersion = flag.Bool("version", false, "print version and exit")
inProcess = flag.Bool("in-process", false, "run the legacy single-process TUI instead of attaching to the daemon")
debugDir = flag.String("debug", "", "write debug logs + per-child raw PTY output to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/debug when DIR is omitted)")
profileDir = flag.String("profile", "", "write pprof files (cpu/heap/goroutine) and live perf counters (metrics.jsonl per-second, metrics.json + summary.txt on exit) to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/profile when DIR is omitted)")
)
@@ -90,8 +72,6 @@ func main() {
}
if *projectDir != "" {
cwd = *projectDir
} else if flag.NArg() > 0 {
cwd = flag.Arg(0)
}
key, err := projectkey.Key(cwd)
if err != nil {
@@ -115,26 +95,11 @@ func main() {
defer stopProfile()
ctx := context.Background()
if *inProcess || os.Getenv("PATTERM_NO_DAEMON") != "" {
if err := app.Run(ctx, app.Options{
ProjectDir: cwd,
ProjectKey: key,
DebugDir: resolvedDebug,
ProfileDir: resolvedProfile,
}); err != nil {
die("%v", err)
}
return
}
if resolvedDebug != "" || resolvedProfile != "" {
die("--debug and --profile currently require --in-process")
}
if err := app.RunAttachedClient(ctx, app.ClientOptions{
if err := app.Run(ctx, app.Options{
ProjectDir: cwd,
Stdin: os.Stdin,
Stdout: os.Stdout,
RawMode: true,
AutoStart: true,
ProjectKey: key,
DebugDir: resolvedDebug,
ProfileDir: resolvedProfile,
}); err != nil {
die("%v", err)
}
@@ -229,141 +194,6 @@ func runMCPProxy() {
}
}
func runDaemonCommand() {
if len(os.Args) >= 2 && os.Args[1] == "stop" {
runDaemonStop()
return
}
if len(os.Args) >= 2 && os.Args[1] == "ls" {
runDaemonList()
return
}
var (
projectDir = flag.String("project", "", "initial project directory (default $PWD)")
listenAddr = flag.String("listen", "", "optional TCP listen address for remote human clients (for example 127.0.0.1:2488, 0.0.0.0:2488, or 2488)")
)
flag.Parse()
cwd, err := os.Getwd()
if err != nil {
die("getwd: %v", err)
}
if *projectDir != "" {
cwd = *projectDir
} else if flag.NArg() > 0 {
cwd = flag.Arg(0)
}
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd, ListenAddr: *listenAddr}); err != nil {
die("daemon: %v", err)
}
}
func runConnectCommand() {
var (
host = flag.String("host", "", "remote daemon host:port")
token = flag.String("token", "", "remote daemon token (default PATTERM_TOKEN or stored token file)")
projectDir = flag.String("project", "", "project directory to request on the daemon")
)
flag.Parse()
if *host == "" && flag.NArg() > 0 {
*host = flag.Arg(0)
}
if *host == "" {
die("connect: --host HOST:PORT is required")
}
tok := *token
if tok == "" {
tok = os.Getenv("PATTERM_TOKEN")
}
if tok == "" {
if stored, err := app.LoadClientToken(); err == nil {
tok = stored
}
}
if tok == "" {
die("connect: token required via --token, PATTERM_TOKEN, or %s", mustTokenPath())
}
cwd := *projectDir
if cwd == "" {
var err error
cwd, err = os.Getwd()
if err != nil {
die("getwd: %v", err)
}
}
tr, err := app.DialTCPTransport(*host)
if err != nil {
die("connect: %v", err)
}
defer tr.Close()
if err := app.RunAttachedClient(context.Background(), app.ClientOptions{
ProjectDir: cwd,
Transport: tr,
Stdin: os.Stdin,
Stdout: os.Stdout,
RawMode: true,
Token: tok,
}); err != nil {
die("connect: %v", err)
}
}
func mustTokenPath() string {
path, err := app.ClientTokenPath()
if err != nil {
return "$XDG_DATA_HOME/patterm/clients/token"
}
return path
}
func runDaemonList() {
projects, err := daemonRequest(protocol.Frame{Type: protocol.FrameList})
if err != nil {
die("ls: %v", err)
}
for _, p := range projects.Projects {
fmt.Printf("%s\t%d\t%s\n", p.Key, p.TabCount, p.Path)
}
}
func runDaemonStop() {
if _, err := daemonRequest(protocol.Frame{Type: protocol.FrameStop}); err != nil {
die("daemon stop: %v", err)
}
fmt.Println("stopped")
}
func daemonRequest(req protocol.Frame) (protocol.ProjectList, error) {
socket, _, err := app.RuntimeDaemonPaths()
if err != nil {
return protocol.ProjectList{}, err
}
conn, err := net.Dial("unix", socket)
if err != nil {
return protocol.ProjectList{}, err
}
defer conn.Close()
t := protocol.NewConnTransport(conn)
if err := t.Send(req); err != nil {
return protocol.ProjectList{}, err
}
resp, err := t.Recv()
if err != nil {
return protocol.ProjectList{}, err
}
if resp.Type == protocol.FrameError {
var msg protocol.Error
_ = json.Unmarshal(resp.Payload, &msg)
if msg.Message == "" {
msg.Message = "daemon returned an error"
}
return protocol.ProjectList{}, fmt.Errorf("%s", msg.Message)
}
if resp.Type != protocol.FrameProjectList {
return protocol.ProjectList{}, fmt.Errorf("unexpected daemon response %q", resp.Type)
}
return protocol.Decode[protocol.ProjectList](resp)
}
func versionString() string {
commit, date := "unknown", "unknown"
if info, ok := debug.ReadBuildInfo(); ok {

View File

@@ -108,7 +108,7 @@ func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthro
}
defer em.Close()
child, err := pty.Start(argv, nil, "", cols, rows)
child, err := pty.Start(argv, nil, cols, rows)
if err != nil {
return fmt.Errorf("pty: %w", err)
}

View File

@@ -1,273 +0,0 @@
# patterm: persistent daemon + thin networked client — implementation plan
Status: implemented — Phases 04 landed on this branch. Branch: `feat/daemon-client-split`.
> Implemented: pty workdir/process-group + protocol/Transport/loopback foundation;
> multi-project `ProjectRegistry`; out-of-process unix-socket daemon with auto-start,
> `daemon stop`/`ls`, detach (Ctrl-]) + reconnect; opt-in LAN TCP listener with a
> lightweight bearer token + `patterm connect`; per-pane display-owner sizing for
> multi-client viewing. Deferred (not built): TLS (transport kept pluggable),
> remote MCP, durable restore of live PTYs across daemon restart.
## Goal
Turn patterm from a single foreground process into a persistent background
**daemon** that owns all process/project state, plus a thin **client** that
renders and forwards input. A client on another LAN device can attach,
navigate projects via the command palette, detach, and reconnect — with child
processes surviving across client disconnects.
## Locked decisions
1. **Scope:** build all phases; land as one PR off this branch.
2. **Remote access:** human UI clients only. MCP for agents stays local
(per-daemon unix socket); no remote MCP transport in this work.
3. **Multi-client = per-client independent view.** The daemon holds pure
process/project state. Each client connection owns a `ClientView`
(selected project, focused pane/pad, scroll offset, palette state,
terminal size). Two clients may sit on different projects at once.
4. **Daemon lifecycle:** auto-start on demand (tmux/docker model). `patterm`
starts the daemon if absent and attaches; `patterm daemon stop|ls` manage it.
5. **Durability:** "persistent" = survive client disconnect while the daemon
process lives. Daemon restart only rehydrates today's persist model
(top-level commands, fresh IDs). No attempt to resurrect live PTYs/agents
after daemon death.
6. **Auth (trusted-network stance):** Harry runs this on a trusted LAN and is
fine with LAN exposure. Keep it lightweight: localhost default, opt-in LAN
bind (`--listen`), a simple pairing/bearer token to prevent accidental
drive-by access. TLS/cert-pinning is NOT required now but the transport must
stay pluggable so TLS can be layered in later.
7. **Detach gesture:** explicit detach via a palette command and/or a dedicated
host chord. Ctrl-D stays as PTY input (shell EOF), as today. Quit-project and
stop-daemon are explicit actions.
## Current architecture (baseline facts — verify before editing)
- `app.Run` (`internal/app/app.go:49`) wires the entire process: presets,
settings, scratchpad/trust/persist stores, in-process MCP server, ONE
`Session`, the `uiState` TUI, classifier, SIGWINCH, 60Hz chrome ticker,
blocking `stdinLoop`.
- **The seam:** `ChildEventListener` (`internal/app/session.go:83`) —
`OnChildSpawned`/`OnChildExited`/`OnPTYOut`/`OnChildStateChanged`/
`OnChildClosed`. Today `uiState` is the only real listener (subscribed at
`app.go:198`). A remote client = a serialized listener + reverse command
channel.
- One `Session` (`session.go:28`) holds a flat `children map[string]*Child` +
`order`. Tabs are derived: `KindAgent` children with `ParentID==""`
(`tree.go` `runningTopLevels`). The whole tree is reconstructed from
`Child.ParentID`.
- `Child` (`child.go:72`) owns `*pty.PTY`, `*vt.GhosttyEmulator`, raw ring,
status/owner atomics. Lifecycle: `Session.Spawn` (`session.go:222`) →
`startPTY``pumpChild` (`session.go:423`, PTY→emulator→ring→`emitPTYOut`)
+ `reapChild` (`session.go:488`, exit→`killDescendantsOf`).
- Stores already keyed by projectKey on `Open`
(`scratchpad`/`trust`/`persist`); `projectkey.Key(dir)` =
`sha256(realpath)[:16]`.
- `SerializeChild` (`session.go:687`) already yields a full VT snapshot for
stateless repaint.
- Rendering writes ANSI to `os.Stdout` under `outMu`; `viewportRenderer`
(`internal/app/viewport_renderer.go`) is a stateful ANSI rewriter confining
child output to the viewport. Input: raw `os.Stdin` via `stdinLoop`
(`app.go:1433`)/`processStdin`.
- MCP: in-process `Server` (`internal/mcp/mcp.go:26`), newline-JSON over a
per-PID unix socket `$XDG_RUNTIME_DIR/patterm/<pid>.sock`. Agents launch
`patterm mcp-stdio --socket S --identity T`. Identity → `callerID` via
`host.ResolveCallerIdentity``Session.FindChildByIdentity`.
- **No TCP/TLS anywhere today.** All `net.Listen`/`net.Dial` are unix sockets.
- **Must-fix:** `pty.Start` (`internal/pty/pty.go:26`) does not set `cmd.Dir`;
today the process `os.Chdir`s once. A daemon can't chdir globally, so
`SpawnSpec.WorkDir` must propagate to `exec.Cmd.Dir`.
## Target component model
| Component | Owns |
|---|---|
| `internal/daemon` (`pattermd`) | Project registry (N `Session`s), all PTYs, emulators, MCP server, per-project stores, classifier, timers. No TTY. |
| `internal/client` (`patterm`) | Real terminal: raw mode, alt-screen, SIGWINCH, stdin/stdout; `uiState`, `viewportRenderer`, chrome draws, palette, input. Holds `ClientView`. |
| `internal/transport` | `Transport` interface + framing; loopback, unix, TCP/TLS impls; auth handshake. |
| `internal/protocol` | Wire message types shared by daemon + client. |
### `Transport` interface (migration linchpin)
```go
type Transport interface {
Send(Frame) error // client→daemon command, or daemon→client push
Recv() (Frame, error)
Close() error
}
```
- **Loopback impl:** in-process channels, zero serialization. Default
`patterm` = client + loopback daemon in one process → today's UX preserved
exactly, single binary.
- **Net impl:** framed JSON-per-line over `net.Conn`, reusing the
`mcp.go:handleConn` pattern; unix socket first, then TCP/TLS.
### Per-client state vs daemon state
```go
// daemon-side, pure process/project state
type Registry struct { projects map[string]*Project } // key = projectKey
type Project struct {
Key, Dir, Name string
Session *Session
Pads *scratchpad.Store
Trust *trust.Store
Persist *persist.Store
Launcher *Launcher
Host *ToolHost
}
// per-connection, client-owned view state (lives client-side; daemon tracks
// only what it must to size emulators + route subscriptions)
type ClientView struct {
ID string
ProjectKey string // which project this client is looking at
FocusedID string // pane (Child) or pad
ScrollOff int
Cols, Rows uint16
// palette state is fully client-local
}
```
Project switch = re-point this client's subscription to another `Project`'s
Session + send `chrome` + `pane_snapshot`. No process teardown.
### Wire protocol (control + UI channel)
Bidirectional framed JSON-per-line.
Daemon → client:
- `hello` / `auth_challenge` / `auth_ok` — handshake.
- `project_list``[{key, path, name, last_active, tab_count}]` for the
palette switcher.
- `chrome` — semantic model for the client's current project+view: tab list
(`runningTopLevels`), sidebar tree (`sidebarNav`), status/owner, toasts,
scratchpad list + selected preview. Client draws chrome locally
(reuses `tabbar.go`/`sidebar.go`).
- `pane_snapshot{paneID, vtBytes}` — full repaint on focus/attach/switch via
`SerializeChild`.
- `pane_chunk{paneID, bytes}` — live focused-pane PTY output (serialized
`OnPTYOut`).
- `lifecycle{spawned|exited|closed|stateChanged,...}` — serialized listener.
- `attention` / `trust_prompt` — human-facing surfaces; render on the client
whose view owns the relevant project.
Client → daemon:
- `attach{token, term_size, project_key?}` / `detach`.
- `input{paneID, bytes}` (the `InjectAsUser` path).
- `focus{paneID|pad}`, `switch_project{key}`, `open_project{path}`.
- `palette_command{...}` (spawn/kill/rename/quit-project), `trust_response`,
`resize{cols,rows}`.
**Encoding decision:** ship raw focused-pane PTY bytes + periodic
`SerializeChild` snapshots; client runs its own `viewportRenderer`. No
daemon-side pre-render (keeps daemon size-agnostic), no grid diffs in v1.
Requires in-order delivery only (TCP gives it). Diffs are a later optimization.
### Emulator sizing with per-client views
Each `Child` emulator has one size. Rules:
- A pane is sized by the client(s) viewing it. If exactly one client focuses a
pane, that client's cols/rows drive `ResizeAll` for that pane.
- If two clients focus the **same** pane, one is the **display owner** (first
to focus, or explicit take-control); the owner's size drives the emulator;
the other letterboxes/clips. Surface a toast.
- Because clients are usually on different projects/panes, contention is rare.
### Security (human clients, LAN — trusted-network stance)
Harry runs this on a trusted LAN (decision #6). Keep it lightweight but not
wide open:
- localhost-only by default. LAN bind (`--listen 0.0.0.0:PORT`) is explicit
opt-in, never default.
- A simple pairing/bearer token gates network attach so a stray host on the LAN
can't drive-by-attach. Daemon prints the token on `--listen`; client presents
it in `attach`; store a per-client token after first pairing.
- Local unix-socket clients keep `0600` perms (sufficient for same-user).
- Keep the transport pluggable so TLS + cert pinning can be layered in later
without reworking the protocol. Not building TLS now.
- Trust prompts may now be approved from another device — deliberate; route to
the client whose view owns the project.
### Daemon lifecycle (auto-start)
- Well-known local socket `$XDG_RUNTIME_DIR/patterm/daemon.sock` +
pidfile/lockfile (single daemon per user).
- `patterm [dir]`: dial the socket; if absent, fork-exec the daemon, wait for
readiness, attach. `--project`/dir selects the initial project for the view.
- `patterm daemon` (foreground), `patterm daemon stop`, `patterm ls`.
- **Detach = explicit** palette command and/or a dedicated host chord; PTYs keep
running. Ctrl-D stays as PTY input (shell EOF). Quitting a project / killing
the daemon are explicit palette/CLI actions.
- Idle-shutdown policy: configurable; default keep alive until explicit stop.
## Package-by-package changes
- **`cmd/patterm`** (`main.go`): add `daemon` subcommand (headless core);
default invocation becomes client (auto-start/attach); `mcp-stdio` dials the
shared daemon socket (not per-PID); `debug-harness` drives a daemon (or
loopback).
- **`internal/app` split:**
- new **`internal/daemon`**: headless half — move `session.go`, `child.go`,
`host.go`, `tree.go`, `launch.go`, classifier, timers, `Shutdown`,
kill-cascade. Add `Registry`/`Project`.
- **`internal/client`**: TTY half — `uiState`, `viewport_renderer.go`,
`screen_renderer.go`, `tabbar.go`, `sidebar.go`, status, `palette.go`,
`stdinLoop`/`processStdin`, SIGWINCH/chrome ticker, markdown/marquee/toast.
Consumes events + chrome over `Transport` instead of `sess.Subscribe`.
- **new `internal/transport` + `internal/protocol`**: messages, framing,
loopback/unix/TCP-TLS impls, auth handshake.
- **`internal/mcp`**: `SocketPath` per-daemon (not per-PID);
`ResolveCallerIdentity` becomes daemon-wide across projects (token already
carries `PATTERM_PROJECT_KEY` via `ChildEnv`).
- **`internal/pty`**: set `cmd.Dir` from `SpawnSpec.WorkDir`; add process-group
handling for reliable tree teardown.
- **`internal/vt`**: unchanged grid source of truth; enforce per-child
serialization around emulator access (interface isn't concurrency-safe) since
clients + MCP + pump all snapshot.
- **`internal/{scratchpad,trust,persist}`**: per-`Project` instances in the
registry (already keyed by projectKey).
- **`internal/preset`**: project-agnostic; daemon loads once, shares.
- **`internal/projectkey`**: doc update (key is now load-bearing for routing).
- **`internal/harness`**: add daemon/loopback mode; assert child survives client
disconnect/reconnect, project-switch preserves each project's tree, two
clients on different projects, unauth TCP rejected.
## Backpressure
`pumpChild`'s listener calls are synchronous (`session.go:149`). A slow network
client must not block the PTY pump. Introduce a per-client event bus with a
bounded buffer that coalesces/ drops to a snapshot under pressure, decoupled
from `pumpChild`.
## Phased roadmap (all phases land on this branch)
0. **Extract headless core behind loopback transport.** `daemon.Core` +
`client` over in-process `Transport`. Zero behavior change; harness green.
1. **Multi-project registry + per-client view scaffolding.** Registry, per-
project stores, `ClientView`, palette "Switch/Open project…", project tier
in chrome. Still single local process.
2. **Out-of-process daemon over unix socket.** Auto-start/attach; PTYs survive
client exit; reconnect + snapshot-on-attach; Ctrl-D = detach; pidfile/lock.
3. **TCP + TLS + auth.** localhost TCP, then opt-in LAN bind; pairing token /
cert pinning; remote trust-prompt routing.
4. **Per-client view fully realized + emulator sizing/display-owner.**
Independent focus/scroll/palette per client; multi-client on same/different
projects; resize negotiation + letterbox.
5. **Hardening.** systemd/launchd autostart, `daemon stop|ls`, idle-shutdown,
backpressure, security review, CHANGELOG.
## Risks / open questions for review
- Heterogeneous client sizes vs one-PTY-one-size (display-owner + letterbox is
the v1 answer — is it sufficient?).
- Security escalation: a network client spawns processes / runs shell / injects
input. Auth/TLS scope adequate?
- Ctrl-D semantics flip — acceptable UX?
- Backpressure design — bounded bus + snapshot-on-pressure correct?
- MCP identity uniqueness across projects after per-PID socket removal.
- Is per-client view (decision #3) worth doing from Phase 1, or staged after a
shared-focus interim that's faster to ship?
- Splitting `uiState` (focus/palette/render caches/trust prompt/dims/outMu) out
of the daemon is the largest refactor — sequencing concerns?

View File

@@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -19,6 +18,7 @@ import (
"golang.org/x/term"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
@@ -60,6 +60,27 @@ func Run(ctx context.Context, opts Options) error {
logf("settings load: %v", 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)
}
// Per-project trust store for command-preset trust gating (SPEC §7).
trustStore, err := trust.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: trust init: %w", err)
}
// Per-project persisted-process store. Survives across patterm
// restarts so user-created top-level command processes come back
// after a relaunch.
persistStore, err := persist.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: persist 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.
@@ -69,10 +90,48 @@ func Run(ctx context.Context, opts Options) error {
}
defer mcpSrv.Close()
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
defer sess.Shutdown()
// Debug capture: when --debug=<dir> is set, write a verbose log
// (patterm.log), per-child raw PTY output (<id>.raw), and a
// JSONL event stream (events.jsonl). Installed before the TUI
// listener so the very first OnChildSpawned / OnPTYOut event
// is captured.
if opts.DebugDir != "" {
dc, err := openDebugCapture(opts.DebugDir)
if err != nil {
return fmt.Errorf("app: debug capture: %w", err)
}
os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath())
sess.Subscribe(dc)
defer dc.Close()
logf("debug capture enabled at %s", opts.DebugDir)
}
// Snapshot persisted processes BEFORE attaching the store: Spawn
// mints fresh ids, so the old records would otherwise linger
// alongside the new ones. Drop them up front; the restore loop
// below re-saves each entry under its new id.
savedProcesses := persistStore.List()
for _, e := range savedProcesses {
_ = persistStore.Remove(e.ID)
}
sess.SetPersistStore(persistStore)
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, trustStore, 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()))
@@ -97,51 +156,28 @@ func Run(ctx context.Context, opts Options) error {
defer metrics.close()
}
registry := newProjectRegistry(presets, appSettings, mcpSrv, layout.childCols(), layout.childRows())
project, err := registry.Open(ctx, opts.ProjectDir)
if err != nil {
return err
}
defer registry.Shutdown()
mcpSrv.SetHost(registry)
if opts.DebugDir != "" {
dc, err := openDebugCapture(opts.DebugDir)
if err != nil {
return fmt.Errorf("app: debug capture: %w", err)
}
os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath())
project.Session.Subscribe(dc)
defer dc.Close()
logf("debug capture enabled at %s", opts.DebugDir)
}
// Per-session idle-detection classifier. One goroutine ticks every
// 250ms over every live child and updates IdleState. It stops when
// ctx is cancelled.
go sess.runClassifier(ctx)
st := &uiState{
registry: registry,
project: project,
sess: project.Session,
presets: presets,
launcher: project.Launcher,
pads: project.Pads,
chromeWake: make(chan struct{}, 1),
trust: project.Trust,
timers: project.Host.timers,
hostCols: cols,
hostRows: rows,
view: ClientView{
ID: "loopback",
ProjectKey: project.Key,
ProjectName: project.Name,
Cols: cols,
Rows: rows,
},
sess: sess,
presets: presets,
launcher: launcher,
pads: pads,
chromeWake: make(chan struct{}, 1),
trust: trustStore,
timers: host.timers,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
metrics: metrics,
settings: appSettings,
settingsPath: settingsPath,
ctx: ctx,
}
st.summaries = newSummaryManager(project.Session, project.Dir, presets, func() autoSummarySettings {
st.summaries = newSummaryManager(sess, opts.ProjectDir, presets, func() autoSummarySettings {
st.settingsMu.Lock()
defer st.settingsMu.Unlock()
return st.settings.AutoSummary.clone()
@@ -153,10 +189,13 @@ func Run(ctx context.Context, opts Options) error {
st.flashError(fmt.Sprintf("summary: %v", result.Error))
}
})
project.Session.SetMetrics(metrics)
st.attachProjectSinks(project)
sess.SetMetrics(metrics)
host.attention = st
host.focus = st
host.prompter = st
host.scratch = st
st.lastExit.Store(-1)
project.Session.Subscribe(st)
sess.Subscribe(st)
go st.summaries.run(ctx)
st.enterScreen()
@@ -167,13 +206,15 @@ func Run(ctx context.Context, opts Options) error {
// Set initial PTY grid for any future child. The child gets the
// computed main viewport, excluding tab bar, sidebar, and status.
registry.ResizeAll(layout.childCols(), layout.childRows())
sess.ResizeAll(layout.childCols(), layout.childRows())
launcher.SetSize(layout.childCols(), layout.childRows())
host.SetSize(layout.childCols(), layout.childRows())
// Replay persisted top-level command processes. Failures are
// logged and skipped so a stale entry (preset deleted, binary
// missing) doesn't block startup.
for _, e := range project.savedProcess {
c, err := project.Launcher.RestoreCommand(e, presets)
for _, e := range savedProcesses {
c, err := launcher.RestoreCommand(e, presets)
if err != nil {
st.dbgf("restore process %s (%s): %v", e.Name, e.ID, err)
continue
@@ -211,7 +252,6 @@ func Run(ctx context.Context, opts Options) error {
}
st.dimsMu.Lock()
st.hostCols, st.hostRows = c, r
st.view.Resize(c, r)
l := st.layoutLocked()
st.dimsMu.Unlock()
st.mu.Lock()
@@ -219,7 +259,9 @@ func Run(ctx context.Context, opts Options) error {
st.renderer.SetLayout(l)
}
st.mu.Unlock()
registry.ResizeAll(l.childCols(), l.childRows())
sess.ResizeAll(l.childCols(), l.childRows())
launcher.SetSize(l.childCols(), l.childRows())
host.SetSize(l.childCols(), l.childRows())
st.clearScreen()
st.drawTabBar()
st.drawSidebar()
@@ -356,8 +398,6 @@ func Run(ctx context.Context, opts Options) error {
// uiState is the shared state between the SIGWINCH loop, the stdin
// loop, and the session listener callbacks.
type uiState struct {
registry *ProjectRegistry
project *Project
sess *Session
presets preset.Set
launcher *Launcher
@@ -368,7 +408,6 @@ type uiState struct {
outMu sync.Mutex
mu sync.Mutex
view ClientView
palette *paletteState
focusedID string
focusedName string
@@ -470,97 +509,6 @@ type uiState struct {
lastExit atomic.Int32
}
func (st *uiState) attachProjectSinks(p *Project) {
p.Host.attention = st
p.Host.focus = st
p.Host.prompter = st
p.Host.scratch = st
}
func (st *uiState) detachProjectSinks(p *Project) {
if p == nil || p.Host == nil {
return
}
if p.Host.attention == st {
p.Host.attention = nil
}
if p.Host.focus == st {
p.Host.focus = nil
}
if p.Host.prompter == st {
p.Host.prompter = nil
}
if p.Host.scratch == st {
p.Host.scratch = nil
}
}
func (st *uiState) switchProject(p *Project) {
if p == nil || p.Session == nil {
return
}
oldProject := st.project
old := st.sess
if old != nil && old != p.Session {
old.Unsubscribe(st)
st.detachProjectSinks(oldProject)
}
st.attachProjectSinks(p)
p.Session.SetMetrics(st.metrics)
if old != p.Session {
p.Session.Subscribe(st)
}
layout := st.layoutSnapshot()
p.Session.ResizeAll(layout.childCols(), layout.childRows())
p.Launcher.SetSize(layout.childCols(), layout.childRows())
p.Host.SetSize(layout.childCols(), layout.childRows())
children := p.Session.Children()
next := firstRunningTopLevel(children)
active := firstRunningAgentID(children)
st.mu.Lock()
st.project = p
st.sess = p.Session
st.launcher = p.Launcher
st.pads = p.Pads
st.trust = p.Trust
st.timers = p.Host.timers
st.view.ProjectKey = p.Key
st.view.ProjectName = p.Name
st.view.FocusedID = ""
st.view.FocusedPad = ""
st.view.ActiveAgentID = active
st.focusedID = ""
st.focusedPad = ""
st.focusedName = ""
st.activeAgentID = active
st.padOffset = 0
st.padOffsetName = ""
st.view.PadOffset = 0
st.view.PadOffsetName = ""
st.renderer = nil
if next != nil {
st.focusChildLocked(next)
st.updateActiveAgentLocked(next)
st.renderer = newViewportRenderer(layout)
}
st.palette = nil
st.mu.Unlock()
st.invalidateScratchpadsCache()
st.invalidateChromeCache()
st.clearScreen()
if next != nil {
st.repaintFocused()
} else {
st.renderEmptyState()
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...)
}
@@ -626,21 +574,6 @@ func (st *uiState) promptTrust(processID, presetName, reason string) {
st.drawStatusLine()
}
func (st *uiState) focusChildLocked(c *Child) {
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.view.FocusChild(c.ID)
}
func (st *uiState) focusPadLocked(name string) {
st.view.FocusPad(name)
st.focusedPad = st.view.FocusedPad
st.focusedID = st.view.FocusedID
st.padOffset = st.view.PadOffset
st.padOffsetName = st.view.PadOffsetName
}
// focusProcess is the SPEC §7 select_process hook. Routes through the
// normal focus-change path; only takes effect if the process exists.
func (st *uiState) focusProcess(processID string) {
@@ -653,7 +586,9 @@ func (st *uiState) focusProcess(processID string) {
onAlt := childIsOnAlt(c)
st.mu.Lock()
leavingPad := st.focusedPad != ""
st.focusChildLocked(c)
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
r := newViewportRenderer(layout)
r.SetChildOnAlt(onAlt)
@@ -716,7 +651,12 @@ func (st *uiState) focusScratchpad(name string) {
}
st.marquee.reset()
st.mu.Lock()
st.focusPadLocked(name)
if st.padOffsetName != name {
st.padOffset = 0
st.padOffsetName = name
}
st.focusedPad = name
st.focusedID = ""
st.focusedName = name
st.renderer = nil
st.mu.Unlock()
@@ -771,7 +711,8 @@ func (st *uiState) restartFocusedCommand(processID string) {
layout := st.layoutSnapshot()
renderer := newViewportRenderer(layout)
st.mu.Lock()
st.focusChildLocked(c)
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.renderer = renderer
st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 2
@@ -806,7 +747,6 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
}
if c.ParentID == "" {
st.activeAgentID = c.ID
st.view.ActiveAgentID = c.ID
return
}
// Walk up to the top-level agent.
@@ -820,7 +760,6 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
}
if root.Kind == KindAgent && root.ParentID == "" {
st.activeAgentID = root.ID
st.view.ActiveAgentID = root.ID
}
}
@@ -883,7 +822,9 @@ func (st *uiState) OnChildSpawned(c *Child) {
layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c)
st.mu.Lock()
st.focusChildLocked(c)
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
renderer := newViewportRenderer(layout)
renderer.SetChildOnAlt(onAlt)
@@ -958,10 +899,10 @@ func (st *uiState) OnChildExited(c *Child) {
if next == nil {
st.focusedID = ""
st.focusedName = ""
st.view.FocusedID = ""
renderEmpty = true
} else {
st.focusChildLocked(next)
st.focusedID = next.ID
st.focusedName = next.DisplayName()
st.updateActiveAgentLocked(next)
st.renderer = newViewportRenderer(layout)
}
@@ -970,7 +911,6 @@ func (st *uiState) OnChildExited(c *Child) {
// The active agent died; pin the agent tree to whatever agent
// root is still running, or clear it if none remain.
st.activeAgentID = firstRunningAgentID(st.sess.Children())
st.view.ActiveAgentID = st.activeAgentID
}
if st.palette != nil {
st.palette.children = st.sess.Children()
@@ -1329,10 +1269,6 @@ func (st *uiState) drawStatusLine() {
palOpen := st.palette != nil
focusID := st.focusedID
focusName := st.focusedName
projectName := ""
if st.project != nil && st.registry != nil && st.registry.Count() > 1 {
projectName = st.project.Name
}
var trustMsg string
if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
@@ -1361,13 +1297,9 @@ func (st *uiState) drawStatusLine() {
owner = "you have control"
}
}
left := projectName
left := ""
if focusName != "" {
if left != "" {
left = left + " · " + focusName
} else {
left = focusName
}
left = focusName
}
if owner != "" {
if left != "" {
@@ -1455,10 +1387,7 @@ func (st *uiState) renderEmptyState() {
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
st.dimsMu.Lock()
defer st.dimsMu.Unlock()
if st.view.Cols == 0 || st.view.Rows == 0 {
return st.hostCols, st.hostRows
}
return st.view.Cols, st.view.Rows
return st.hostCols, st.hostRows
}
func (st *uiState) layoutSnapshot() terminalLayout {
@@ -1468,10 +1397,7 @@ func (st *uiState) layoutSnapshot() terminalLayout {
}
func (st *uiState) layoutLocked() terminalLayout {
if st.view.Cols == 0 || st.view.Rows == 0 {
return newTerminalLayout(st.hostCols, st.hostRows)
}
return newTerminalLayout(st.view.Cols, st.view.Rows)
return newTerminalLayout(st.hostCols, st.hostRows)
}
// splitOnEnter walks input and returns each Enter byte (CR or LF) as
@@ -2040,20 +1966,6 @@ func (st *uiState) openPaletteLocked() {
appSettings := st.settings.clone()
st.settingsMu.Unlock()
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings)
if st.registry != nil {
projects := st.registry.Summaries(st.view.ProjectKey)
palProjects := make([]paletteProject, 0, len(projects))
for _, p := range projects {
palProjects = append(palProjects, paletteProject{
Key: p.Key,
Dir: p.Dir,
Name: p.Name,
TabCount: p.TabCount,
IsCurrent: p.IsCurrent,
})
}
st.palette.setProjects(st.view.ProjectKey, palProjects)
}
// Push a "no kitty flags" entry onto the host terminal's keyboard
// stack so palette input arrives in plain legacy form regardless of
// what the focused child pushed. Codex/ratatui enables kitty mode
@@ -2174,7 +2086,9 @@ func (st *uiState) closePalette(action paletteAction) {
layout := st.layoutSnapshot()
st.mu.Lock()
leavingPad := st.focusedPad != ""
st.focusChildLocked(c)
st.focusedPad = ""
st.focusedID = action.childID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout)
st.mu.Unlock()
@@ -2189,42 +2103,6 @@ func (st *uiState) closePalette(action paletteAction) {
st.drawSidebar()
st.drawStatusLine()
case "project-switch":
if st.registry == nil || action.projectKey == "" {
restoreView()
return
}
if p := st.registry.Project(action.projectKey); p != nil {
st.switchProject(p)
return
}
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "project-open-submit":
if st.registry == nil || strings.TrimSpace(action.projectPath) == "" {
restoreView()
return
}
path := strings.TrimSpace(action.projectPath)
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
p, err := st.registry.Open(st.ctx, path)
if err != nil {
st.flashError(fmt.Sprintf("open project: %v", err))
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return
}
st.switchProject(p)
case "kill":
// User-initiated kill cancels any pending auto-restart so the
// process doesn't immediately come back.
@@ -2354,8 +2232,13 @@ func (st *uiState) handlePadDelete(name string) {
if entries := st.padsList(); len(entries) > 0 {
next := entries[0].Name
st.mu.Lock()
st.focusPadLocked(next)
st.focusedPad = next
st.focusedID = ""
st.focusedName = next
if st.padOffsetName != next {
st.padOffset = 0
st.padOffsetName = next
}
st.mu.Unlock()
st.repaintFocusedWithChrome()
return
@@ -2366,12 +2249,9 @@ func (st *uiState) handlePadDelete(name string) {
}
st.mu.Lock()
st.focusedPad = ""
st.view.FocusedPad = ""
st.focusedName = ""
st.padOffset = 0
st.padOffsetName = ""
st.view.PadOffset = 0
st.view.PadOffsetName = ""
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar()
@@ -2398,7 +2278,7 @@ func (st *uiState) handlePadRename(oldName, newName string) {
}
st.mu.Lock()
if st.focusedPad == oldName {
st.focusPadLocked(newName)
st.focusedPad = newName
}
st.mu.Unlock()
st.scratchpadsChanged()
@@ -2669,7 +2549,6 @@ func (st *uiState) renderPadView(name, content string, layout terminalLayout) []
st.padOffset = 0
}
offset := st.padOffset
st.view.PadOffset = offset
st.mu.Unlock()
var b strings.Builder
@@ -2727,7 +2606,6 @@ func (st *uiState) exitPadView() {
return
}
st.focusedPad = ""
st.view.FocusedPad = ""
st.focusedName = ""
st.mu.Unlock()
st.clearViewportArea()
@@ -2754,7 +2632,6 @@ func (st *uiState) padScroll(delta int) {
if st.padOffset < 0 {
st.padOffset = 0
}
st.view.PadOffset = st.padOffset
st.mu.Unlock()
st.repaintFocusedPad()
}

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

@@ -0,0 +1,143 @@
package app
import (
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
var (
statusVolatileRE = regexp.MustCompile(`\b(?:\d+h\s*)?\d+m\s*\d+s\b|\b\d{1,2}:\d{2}(?::\d{2})?\b|\b\d+(?:\.\d+)?s\b`)
counterRE = regexp.MustCompile(`\b\d+\s*/\s*\d+\b|\b\d{1,3}%`)
spinnerGlyphRE = regexp.MustCompile(`^[\s⠁⠂⠄⡀⢀⠠⠐⠈⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏•·∙◐◓◑◒]+`)
)
func canonicalizeTerminalText(s string, maxLines int) (string, bool, int) {
s = string(stripANSIBytes(nil, []byte(s)))
s = strings.ReplaceAll(s, "\r\n", "\n")
s = carriageReturnToLines(s)
s = strings.ReplaceAll(s, "\r", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
pendingBlank := false
for _, raw := range lines {
line := strings.TrimRightFunc(stripControlRunes(raw), unicode.IsSpace)
if strings.TrimSpace(line) == "" {
if len(out) > 0 {
pendingBlank = true
}
continue
}
if isBorderOnlyLine(line) {
continue
}
line = canonicalStatusLine(line)
if len(out) > 0 && out[len(out)-1] == line {
pendingBlank = false
continue
}
if pendingBlank {
out = append(out, "")
pendingBlank = false
}
out = append(out, line)
}
if maxLines > 0 && len(out) > maxLines {
dropped := strings.Join(out[:len(out)-maxLines], "\n")
out = out[len(out)-maxLines:]
return strings.Join(out, "\n"), true, len(dropped)
}
return strings.Join(out, "\n"), false, 0
}
func carriageReturnToLines(s string) string {
var out []string
var current strings.Builder
flush := func() {
out = append(out, current.String())
current.Reset()
}
for len(s) > 0 {
r, size := utf8.DecodeRuneInString(s)
s = s[size:]
switch r {
case '\r':
current.Reset()
case '\n':
flush()
default:
current.WriteRune(r)
}
}
if current.Len() > 0 || len(out) == 0 {
flush()
}
return strings.Join(out, "\n")
}
func stripControlRunes(s string) string {
return strings.Map(func(r rune) rune {
if r == '\t' || r == '\n' {
return r
}
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}
func isBorderOnlyLine(s string) bool {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return false
}
seenBox := false
for _, r := range trimmed {
if r >= 0x2500 && r <= 0x257f {
seenBox = true
continue
}
switch r {
case ' ', '\t', '-', '_', '=', '+', '|', ':', '.', '\'', '"', '`', '*':
continue
default:
return false
}
}
return seenBox
}
func canonicalStatusLine(s string) string {
if !looksStatusLike(s) {
return s
}
leading := len(s) - len(strings.TrimLeftFunc(s, unicode.IsSpace))
prefix := s[:leading]
body := s[leading:]
body = spinnerGlyphRE.ReplaceAllString(body, "")
body = statusVolatileRE.ReplaceAllString(body, "[time]")
body = counterRE.ReplaceAllString(body, "[count]")
return prefix + strings.TrimRightFunc(body, unicode.IsSpace)
}
func looksStatusLike(s string) bool {
lower := strings.ToLower(s)
for _, token := range []string{
"status", "running", "remaining", "progress", "loading",
"building", "installing", "downloading", "waiting", "working",
} {
if strings.Contains(lower, token) {
return true
}
}
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return false
}
r, _ := utf8.DecodeRuneInString(trimmed)
return strings.ContainsRune("⠁⠂⠄⡀⢀⠠⠐⠈⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏•·∙◐◓◑◒", r)
}

View File

@@ -0,0 +1,167 @@
package app
import (
"strings"
"testing"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
)
func TestCanonicalizeTerminalText(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "ansi osc and controls",
in: "\x1b]0;title\x07\x1b[31mred\x1b[0m\x00\nok",
want: "red\nok",
},
{
name: "noisy harness stream",
in: "\x1b]0;noise\x07\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\n╭────╮\n│ │\nDownloading 10%\rDownloading 100%\nFINAL: deploy ready\n",
want: "Status: running [time]\nDownloading [count]\nFINAL: deploy ready",
},
{
name: "repeated blank collapse",
in: "one\n\n\n two\n \n\t\nthree",
want: "one\n\n two\n\nthree",
},
{
name: "border only box drawing removal",
in: "╭────────╮\n│ │\nimportant\n╰────────╯",
want: "important",
},
{
name: "carriage return progress coalesces final frame",
in: "Downloading 10%\rDownloading 20%\rDownloading 100%\nDone",
want: "Downloading [count]\nDone",
},
{
name: "volatile timer duplicate collapse",
in: "Status: running 12s\nStatus: running 13s\nStatus: running 01:23",
want: "Status: running [time]",
},
{
name: "duplicate status row collapse",
in: "⠋ Building 1/4\n⠙ Building 2/4\n⠹ Building 3/4\nready",
want: "Building [count]\nready",
},
{
name: "preserve meaningful indented code and tables",
in: " if elapsed == 12s {\n return value\n }\n| name | value |\n| a | 1 |",
want: " if elapsed == 12s {\n return value\n }\n| name | value |\n| a | 1 |",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, truncated, _ := canonicalizeTerminalText(tc.in, 120)
if truncated {
t.Fatalf("unexpected truncation")
}
if got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
})
}
}
func TestCanonicalizeTerminalTextMaxLines(t *testing.T) {
got, truncated, dropped := canonicalizeTerminalText("one\ntwo\nthree", 2)
if !truncated {
t.Fatalf("expected truncation")
}
if dropped == 0 {
t.Fatalf("expected dropped bytes")
}
if got != "two\nthree" {
t.Fatalf("got %q", got)
}
}
func TestGetProcessOutputStreamCanonicalByDefault(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "")
addChild(sess, c)
c.recordWrite([]byte("\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\nresult\n"))
host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24)
out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream"})
if err != nil {
t.Fatal(err)
}
if !out.Canonicalized {
t.Fatalf("expected canonicalized output")
}
if out.Content != "Status: running [time]\nresult" {
t.Fatalf("content = %q", out.Content)
}
if out.Cursor != nil || out.Rows != 0 || out.Cols != 0 || out.ScreenVersion != 0 || out.IdleMS != 0 {
t.Fatalf("default output should be metadata-light: %#v", out)
}
}
func TestGetProcessOutputRawReturnsStreamBytes(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "")
addChild(sess, c)
c.recordWrite([]byte("\x1b[31mred\x1b[0m"))
host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24)
out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "grid", Raw: true})
if err != nil {
t.Fatal(err)
}
if out.Mode != "stream" {
t.Fatalf("raw grid mode should report stream semantics, got %q", out.Mode)
}
if out.Canonicalized {
t.Fatalf("raw output should not be canonicalized")
}
if out.Content != "\x1b[31mred\x1b[0m" {
t.Fatalf("content = %q", out.Content)
}
if out.NewOffset != int64(len(out.Content)) {
t.Fatalf("new_offset=%d want %d", out.NewOffset, len(out.Content))
}
}
func TestGetProcessOutputCanonicalAfterRawRead(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "")
addChild(sess, c)
c.recordWrite([]byte("\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\nDownloading 10%\rDownloading 100%\nFINAL: deploy ready\n"))
host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24)
if _, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", Raw: true}); err != nil {
t.Fatal(err)
}
out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", MaxLines: 20})
if err != nil {
t.Fatal(err)
}
if out.Content != "Status: running [time]\nDownloading [count]\nFINAL: deploy ready" {
t.Fatalf("content = %q", out.Content)
}
}
func TestGetProcessOutputIncludeMetaRestoresFields(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "")
addChild(sess, c)
c.recordWrite([]byte("ok"))
host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24)
out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", IncludeMeta: true})
if err != nil {
t.Fatal(err)
}
if out.ScreenVersion == 0 {
t.Fatalf("screen_version missing with include_meta: %#v", out)
}
if !strings.Contains(out.Content, "ok") {
t.Fatalf("content = %q", out.Content)
}
}

View File

@@ -228,7 +228,7 @@ func (c *Child) startPTY(cols, rows uint16) (uint64, error) {
}
starting := StatusStarting
c.status.Store(&starting)
p, err := pkgpty.Start(c.Argv, c.Env, c.WorkDir, cols, rows)
p, err := pkgpty.Start(c.Argv, c.Env, cols, rows)
if err != nil {
em.Close()
errored := StatusErrored
@@ -532,6 +532,12 @@ func (c *Child) StreamRead(since int64) ([]byte, int64) {
return out, end
}
func (c *Child) StreamOffset() int64 {
c.ringMu.Lock()
defer c.ringMu.Unlock()
return c.ringWrites
}
func (c *Child) signal(sig syscall.Signal) error {
pty := c.PTY()
if pty == nil {

View File

@@ -1,80 +0,0 @@
package app
import "github.com/hjbdev/patterm/internal/scratchpad"
// chromeModel is the semantic host chrome state. Renderers continue to own
// ANSI output; this model is the serializable shape a client can draw locally.
type chromeModel struct {
ProjectKey string `json:"project_key"`
ProjectName string `json:"project_name,omitempty"`
FocusedID string `json:"focused_id,omitempty"`
FocusedPad string `json:"focused_pad,omitempty"`
ActiveAgentID string `json:"active_agent_id,omitempty"`
Tabs []childModel `json:"tabs"`
Processes []childModel `json:"processes"`
AgentTree []childModel `json:"agent_tree"`
Sidebar []navEntryModel `json:"sidebar"`
Scratchpads []scratchpadModel `json:"scratchpads"`
}
type childModel struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
ParentID string `json:"parent_id,omitempty"`
Status string `json:"status"`
Owner string `json:"owner"`
}
type navEntryModel struct {
ChildID string `json:"child_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type scratchpadModel struct {
Name string `json:"name"`
}
func buildChromeModel(projectKey string, view ClientView, children []*Child, pads []scratchpad.Entry) chromeModel {
active := view.ActiveAgentID
if active == "" {
active = activeRootID(children, view.FocusedID)
}
model := chromeModel{
ProjectKey: projectKey,
ProjectName: view.ProjectName,
FocusedID: view.FocusedID,
FocusedPad: view.FocusedPad,
ActiveAgentID: active,
}
for _, c := range runningTopLevels(children) {
model.Tabs = append(model.Tabs, serializeChildModel(c))
}
for _, c := range processList(children) {
model.Processes = append(model.Processes, serializeChildModel(c))
}
for _, c := range visibleAgentTree(children, active) {
model.AgentTree = append(model.AgentTree, serializeChildModel(c))
}
for _, n := range sidebarNav(children, active, pads) {
model.Sidebar = append(model.Sidebar, navEntryModel{ChildID: n.childID, Pad: n.pad})
}
for _, p := range pads {
model.Scratchpads = append(model.Scratchpads, scratchpadModel{Name: p.Name})
}
return model
}
func serializeChildModel(c *Child) childModel {
if c == nil {
return childModel{}
}
return childModel{
ID: c.ID,
Name: c.DisplayName(),
Kind: string(c.Kind),
ParentID: c.ParentID,
Status: string(c.Status()),
Owner: string(c.Owner()),
}
}

View File

@@ -1,24 +0,0 @@
package app
import "testing"
func TestBuildChromeModelSeparatesProcessesTabsAndSidebar(t *testing.T) {
running := StatusRunning
proc := testProcess("p1", "server", running)
agent := testAgent("a1", "codex", "", running)
sub := testAgent("a2", "worker", "a1", running)
model := buildChromeModel("project", ClientView{FocusedID: "p1", ActiveAgentID: "a1"}, []*Child{proc, agent, sub}, nil)
if len(model.Tabs) != 1 || model.Tabs[0].ID != "a1" {
t.Fatalf("tabs = %#v, want only top-level agent", model.Tabs)
}
if len(model.Processes) != 1 || model.Processes[0].ID != "p1" {
t.Fatalf("processes = %#v, want process section", model.Processes)
}
if len(model.AgentTree) != 2 || model.AgentTree[0].ID != "a1" || model.AgentTree[1].ID != "a2" {
t.Fatalf("agent tree = %#v", model.AgentTree)
}
if len(model.Sidebar) != 3 || model.Sidebar[0].ChildID != "p1" || model.Sidebar[1].ChildID != "a1" {
t.Fatalf("sidebar = %#v", model.Sidebar)
}
}

View File

@@ -1,677 +0,0 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
cpty "github.com/creack/pty"
"golang.org/x/term"
"github.com/hjbdev/patterm/internal/protocol"
)
const (
clientKeyCtrlK byte = 0x0b
clientKeyCtrlBracket byte = 0x1d
)
type ClientOptions struct {
ProjectDir string
Transport protocol.Transport
Stdin io.Reader
Stdout io.Writer
RawMode bool
AutoStart bool
Token string
Cols uint16
Rows uint16
}
func RunAttachedClient(ctx context.Context, opts ClientOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return err
}
opts.ProjectDir = cwd
}
if opts.Stdin == nil {
opts.Stdin = os.Stdin
}
if opts.Stdout == nil {
opts.Stdout = os.Stdout
}
if opts.Transport == nil {
t, err := dialDaemonTransport(opts.ProjectDir, opts.AutoStart)
if err != nil {
return err
}
opts.Transport = t
defer t.Close()
}
if opts.Cols == 0 || opts.Rows == 0 {
opts.Cols, opts.Rows = clientHostSize(opts.Stdin)
}
c := newNetClient(opts)
return c.run(ctx)
}
func DialTCPTransport(addr string) (protocol.Transport, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, err
}
return protocol.NewConnTransport(conn), nil
}
func dialDaemonTransport(projectDir string, autoStart bool) (protocol.Transport, error) {
socket, _, err := RuntimeDaemonPaths()
if err != nil {
return nil, err
}
conn, err := net.Dial("unix", socket)
if err == nil {
return protocol.NewConnTransport(conn), nil
}
if !autoStart {
return nil, err
}
if err := startDaemonProcess(projectDir); err != nil {
return nil, err
}
deadline := time.Now().Add(5 * time.Second)
var last error
for time.Now().Before(deadline) {
conn, err = net.Dial("unix", socket)
if err == nil {
return protocol.NewConnTransport(conn), nil
}
last = err
time.Sleep(50 * time.Millisecond)
}
return nil, fmt.Errorf("daemon did not become ready: %w", last)
}
func startDaemonProcess(projectDir string) error {
exe, err := os.Executable()
if err != nil {
return err
}
cmd := exec.Command(exe, "daemon", "--project", projectDir)
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err == nil {
defer devNull.Close()
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
}
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
type netClient struct {
t protocol.Transport
in io.Reader
out io.Writer
raw bool
projectDir string
token string
layout terminalLayout
mu sync.Mutex
focusedID string
paneSize protocol.Size
ownerView bool
chrome chromeModel
renderer *viewportRenderer
palette *clientCommandPrompt
}
type clientCommandPrompt struct {
buf []byte
}
func newNetClient(opts ClientOptions) *netClient {
layout := newTerminalLayout(opts.Cols, opts.Rows)
return &netClient{
t: opts.Transport,
in: opts.Stdin,
out: opts.Stdout,
raw: opts.RawMode,
projectDir: opts.ProjectDir,
token: opts.Token,
layout: layout,
renderer: newViewportRenderer(layout),
}
}
func (c *netClient) run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var restore *term.State
if c.raw {
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
st, err := term.MakeRaw(int(f.Fd()))
if err != nil {
return err
}
restore = st
defer term.Restore(int(f.Fd()), restore)
}
}
c.enterScreen()
defer c.leaveScreen()
if err := c.sendAttach(); err != nil {
return err
}
errCh := make(chan error, 2)
go func() { errCh <- c.recvLoop(ctx, cancel) }()
go func() { errCh <- c.stdinLoop(ctx, cancel) }()
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
defer signal.Stop(winch)
go func() {
for {
select {
case <-ctx.Done():
return
case <-winch:
cols, rows := clientHostSize(c.in)
_ = c.resize(cols, rows)
c.enterScreen()
c.drawChrome()
}
}
}()
}
select {
case <-ctx.Done():
_ = c.t.Close()
return nil
case err := <-errCh:
cancel()
_ = c.t.Close()
if errors.Is(err, io.EOF) || errors.Is(err, protocol.ErrTransportClosed) {
return nil
}
return err
}
}
func (c *netClient) sendAttach() error {
f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{
ProjectPath: c.projectPath(),
Token: c.token,
TermSize: protocol.Size{
Cols: c.layout.childCols(),
Rows: c.layout.childRows(),
},
})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) projectPath() string {
return c.projectDir
}
func (c *netClient) recvLoop(ctx context.Context, cancel func()) error {
for {
select {
case <-ctx.Done():
return nil
default:
}
f, err := c.t.Recv()
if err != nil {
return err
}
if err := c.handleFrame(f); err != nil {
return err
}
if f.Type == protocol.FrameDetach {
cancel()
return nil
}
}
}
func (c *netClient) handleFrame(f protocol.Frame) error {
switch f.Type {
case protocol.FrameError:
msg, _ := protocol.Decode[protocol.Error](f)
if msg.Message == "" {
msg.Message = "daemon error"
}
return fmt.Errorf("%s", msg.Message)
case protocol.FrameHello:
return nil
case protocol.FrameProjectList:
return nil
case protocol.FrameChrome:
msg, err := protocol.Decode[protocol.Chrome](f)
if err != nil {
return err
}
var model chromeModel
if err := json.Unmarshal(msg.Model, &model); err != nil {
return err
}
c.mu.Lock()
c.chrome = model
if model.FocusedID != "" {
c.focusedID = model.FocusedID
}
c.mu.Unlock()
c.drawChrome()
case protocol.FramePaneSnapshot:
msg, err := protocol.Decode[protocol.PaneSnapshot](f)
if err != nil {
return err
}
c.mu.Lock()
c.focusedID = msg.PaneID
c.paneSize = msg.Size
c.ownerView = msg.DisplayOwner
c.renderer = newViewportRenderer(c.renderLayoutLocked(msg.Size))
renderer := c.renderer
c.mu.Unlock()
c.clearViewport()
c.drawChrome()
c.writeWrapped(renderer.Render(msg.Bytes))
case protocol.FramePaneChunk:
msg, err := protocol.Decode[protocol.PaneChunk](f)
if err != nil {
return err
}
c.mu.Lock()
focused := c.focusedID
renderer := c.renderer
c.paneSize = msg.Size
c.ownerView = msg.DisplayOwner
if renderer != nil && (msg.Size.Cols != 0 || msg.Size.Rows != 0) {
renderer.SetLayout(c.renderLayoutLocked(msg.Size))
}
c.mu.Unlock()
if msg.PaneID == focused && renderer != nil {
c.writeWrapped(renderer.Render(msg.Bytes))
}
case protocol.FrameLifecycle:
// The daemon follows lifecycle changes with chrome/snapshot updates
// when focus changes. Keep this as a wake point for future richer
// client-side state without blocking the frame stream.
return nil
}
return nil
}
func (c *netClient) stdinLoop(ctx context.Context, cancel func()) error {
buf := make([]byte, 4096)
for {
n, err := c.in.Read(buf)
if n > 0 {
if done, perr := c.processInput(buf[:n]); perr != nil || done {
cancel()
return perr
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
select {
case <-ctx.Done():
return nil
default:
}
}
}
func (c *netClient) processInput(chunk []byte) (bool, error) {
c.mu.Lock()
if c.palette != nil {
p := c.palette
c.mu.Unlock()
return c.processPaletteInput(p, chunk)
}
c.mu.Unlock()
forward := make([]byte, 0, len(chunk))
flush := func() error {
if len(forward) == 0 {
return nil
}
c.mu.Lock()
paneID := c.focusedID
c.mu.Unlock()
if paneID != "" {
f, err := protocol.NewFrame(protocol.FrameInput, protocol.Input{PaneID: paneID, Bytes: append([]byte(nil), forward...)})
if err != nil {
return err
}
if err := c.t.Send(f); err != nil {
return err
}
}
forward = forward[:0]
return nil
}
for _, b := range chunk {
switch b {
case clientKeyCtrlBracket:
if err := flush(); err != nil {
return false, err
}
return true, c.sendDetach()
case clientKeyCtrlK:
if err := flush(); err != nil {
return false, err
}
c.mu.Lock()
c.palette = &clientCommandPrompt{}
c.mu.Unlock()
c.drawPrompt()
case 0x17: // Ctrl-W: previous focus
if err := flush(); err != nil {
return false, err
}
_ = c.focusRelative(-1)
case 0x13: // Ctrl-S: next focus
if err := flush(); err != nil {
return false, err
}
_ = c.focusRelative(1)
default:
forward = append(forward, b)
}
}
return false, flush()
}
func (c *netClient) processPaletteInput(p *clientCommandPrompt, chunk []byte) (bool, error) {
for _, b := range chunk {
switch b {
case 0x1b: // ESC
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
c.drawChrome()
return false, nil
case 'd':
if len(p.buf) == 0 {
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
return true, c.sendDetach()
}
p.buf = append(p.buf, b)
case '\r', '\n':
command := strings.TrimSpace(string(p.buf))
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
if command == "" {
c.drawChrome()
return false, nil
}
return false, c.sendSpawnCommand(command)
case 0x7f, 0x08:
if len(p.buf) > 0 {
p.buf = p.buf[:len(p.buf)-1]
}
c.drawPrompt()
default:
if b >= 0x20 {
p.buf = append(p.buf, b)
c.drawPrompt()
}
}
}
return false, nil
}
func (c *netClient) sendDetach() error {
f, err := protocol.NewFrame(protocol.FrameDetach, protocol.Detach{})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) sendSpawnCommand(command string) error {
data, err := json.Marshal(map[string]any{
"argv": []string{command},
"name": command,
"shell": true,
})
if err != nil {
return err
}
f, err := protocol.NewFrame(protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) focusRelative(delta int) error {
c.mu.Lock()
model := c.chrome
current := c.focusedID
c.mu.Unlock()
ids := make([]string, 0, len(model.Processes)+len(model.AgentTree)+len(model.Tabs))
for _, n := range model.Sidebar {
if n.ChildID != "" {
ids = append(ids, n.ChildID)
}
}
if len(ids) == 0 {
for _, p := range model.Processes {
ids = append(ids, p.ID)
}
for _, p := range model.Tabs {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
idx := 0
for i, id := range ids {
if id == current {
idx = i
break
}
}
idx = (idx + delta + len(ids)) % len(ids)
f, err := protocol.NewFrame(protocol.FrameFocus, protocol.Focus{PaneID: ids[idx]})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) resize(cols, rows uint16) error {
c.mu.Lock()
c.layout = newTerminalLayout(cols, rows)
if c.renderer != nil {
c.renderer.SetLayout(c.renderLayoutLocked(c.paneSize))
}
size := protocol.Size{Cols: c.layout.childCols(), Rows: c.layout.childRows()}
c.mu.Unlock()
f, err := protocol.NewFrame(protocol.FrameResize, protocol.Resize{Size: size})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) renderLayoutLocked(size protocol.Size) terminalLayout {
l := c.layout
if size.Cols != 0 && size.Cols < l.mainCols {
l.mainCols = size.Cols
}
if size.Rows != 0 && size.Rows < l.mainRows {
l.mainRows = size.Rows
}
return l
}
func (c *netClient) enterScreen() {
_, _ = c.out.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h"))
c.installScrollRegion()
}
func (c *netClient) leaveScreen() {
_, _ = c.out.Write([]byte("\x1b[r\x1b[?6l\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"))
}
func (c *netClient) installScrollRegion() {
mainBottom := int(c.layout.statusRow) - statusRows
if mainBottom < int(c.layout.mainTop) {
return
}
fmt.Fprintf(c.out, "\x1b[?6l\x1b[%d;%dr\x1b[%d;%dH",
int(c.layout.mainTop), mainBottom,
int(c.layout.mainTop), int(c.layout.mainLeft))
}
func (c *netClient) clearViewport() {
for row := int(c.layout.mainTop); row < int(c.layout.statusRow); row++ {
fmt.Fprintf(c.out, "\x1b[%d;%dH\x1b[%dX", row, int(c.layout.mainLeft), int(c.layout.childCols()))
}
fmt.Fprintf(c.out, "\x1b[%d;%dH", int(c.layout.mainTop), int(c.layout.mainLeft))
}
func (c *netClient) writeWrapped(out []byte) {
if len(out) == 0 {
return
}
wrapped := make([]byte, 0, len(out)+10)
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
_, _ = c.out.Write(wrapped)
}
func (c *netClient) drawChrome() {
c.mu.Lock()
model := c.chrome
prompt := c.palette
c.mu.Unlock()
var b strings.Builder
width := int(c.layout.childCols())
fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX\x1b[2;1H\x1b[%dX\x1b[3;1H\x1b[%dX", width, width, width)
if len(model.Tabs) == 0 {
fmt.Fprintf(&b, "\x1b[1;2H%s+ new%s", styleDim, styleReset)
} else {
col := 1
for _, tab := range model.Tabs {
label := fitName(tab.Name, 18)
style := styleHint
if tab.ID == model.ActiveAgentID || tab.ID == model.FocusedID {
style = styleActive
}
fmt.Fprintf(&b, "\x1b[1;%dH%s %s %s", col, style, label, styleReset)
col += visibleLen(label) + 3
if col >= width {
break
}
}
}
fmt.Fprintf(&b, "\x1b[3;1H%s%s%s", styleBorder, strings.Repeat("─", width), styleReset)
if c.layout.sidebarVisible {
c.appendSidebar(&b, model)
}
status := "Ctrl-K command palette · Ctrl-] detach"
if model.FocusedID != "" {
status = fmt.Sprintf("%s · %s", model.FocusedID, status)
}
c.mu.Lock()
size := c.paneSize
ownerView := c.ownerView
c.mu.Unlock()
if model.FocusedID != "" && !ownerView && size.Cols != 0 && size.Rows != 0 {
status = fmt.Sprintf("viewing at owner size %dx%d · %s", size.Cols, size.Rows, status)
}
if prompt != nil {
status = "command: " + string(prompt.buf)
}
fmt.Fprintf(&b, "\x1b[%d;1H\x1b[7m%s%s", int(c.layout.statusRow), fitName(status, int(c.layout.hostCols)), styleReset)
_, _ = c.out.Write([]byte(b.String()))
}
func (c *netClient) appendSidebar(b *strings.Builder, model chromeModel) {
border := int(c.layout.sidebarLeft) - 1
for row := 1; row <= int(c.layout.statusRow)-1; row++ {
fmt.Fprintf(b, "\x1b[%d;%dH%s│%s", row, border, styleBorder, styleReset)
}
col := int(c.layout.sidebarLeft)
row := 1
write := func(text string) {
if row >= int(c.layout.statusRow) {
return
}
fmt.Fprintf(b, "\x1b[%d;%dH%-*s", row, col, int(c.layout.sidebarWidth)-1, fitName(text, int(c.layout.sidebarWidth)-1))
row++
}
write(styleActive + "Processes" + styleReset)
for _, p := range model.Processes {
prefix := " "
if p.ID == model.FocusedID {
prefix = "▎ "
}
write(prefix + p.Name)
}
row++
write(styleActive + "Agent Tree" + styleReset)
for _, p := range model.AgentTree {
prefix := " "
if p.ID == model.FocusedID {
prefix = "▎ "
}
write(prefix + p.Name)
}
row++
write(styleActive + "Scratchpads" + styleReset)
for _, p := range model.Scratchpads {
write(" " + p.Name)
}
}
func (c *netClient) drawPrompt() {
c.drawChrome()
}
func clientHostSize(r io.Reader) (cols, rows uint16) {
if f, ok := r.(*os.File); ok {
ws, err := cpty.GetsizeFull(f)
if err == nil && ws.Cols > 0 && ws.Rows > 0 {
return ws.Cols, ws.Rows
}
}
return 120, 40
}

View File

@@ -1,157 +0,0 @@
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"sync"
"testing"
"time"
"github.com/hjbdev/patterm/internal/protocol"
)
func TestNetClientFrameLoopSendsFocusedInput(t *testing.T) {
clientT, daemonT := protocol.NewLoopbackPair()
inR, inW := ioPipe(t)
out := &lockedBuffer{}
gotInput := make(chan protocol.Input, 1)
errCh := make(chan error, 1)
go func() {
f, err := daemonT.Recv()
if err != nil {
errCh <- err
return
}
if f.Type != protocol.FrameAttach {
t.Errorf("first frame = %s, want attach", f.Type)
errCh <- nil
return
}
sendTestFrame(t, daemonT, protocol.FrameHello, protocol.Hello{Version: 1, ClientID: "test", ProjectKey: "project"})
sendTestFrame(t, daemonT, protocol.FrameProjectList, protocol.ProjectList{})
model := chromeModel{
ProjectKey: "project",
FocusedID: "p1",
Processes: []childModel{{ID: "p1", Name: "shell", Kind: string(KindCommand), Status: string(StatusRunning)}},
Sidebar: []navEntryModel{{ChildID: "p1"}},
}
sendTestFrame(t, daemonT, protocol.FrameChrome, protocol.Chrome{ProjectKey: "project", Model: mustMarshalTest(t, model)})
sendTestFrame(t, daemonT, protocol.FramePaneSnapshot, protocol.PaneSnapshot{PaneID: "p1", Bytes: []byte("READY")})
for {
f, err := daemonT.Recv()
if err != nil {
errCh <- err
return
}
if f.Type != protocol.FrameInput {
continue
}
input, err := protocol.Decode[protocol.Input](f)
if err != nil {
errCh <- err
return
}
gotInput <- input
_ = daemonT.Close()
errCh <- nil
return
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runCh := make(chan error, 1)
go func() {
runCh <- RunAttachedClient(ctx, ClientOptions{
Transport: clientT,
Stdin: inR,
Stdout: out,
Cols: 80,
Rows: 24,
})
}()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) && !bytes.Contains(out.Bytes(), []byte("READY")) {
time.Sleep(10 * time.Millisecond)
}
if !bytes.Contains(out.Bytes(), []byte("READY")) {
t.Fatalf("snapshot was not rendered before input; output=%q", out.String())
}
if _, err := inW.Write([]byte("echo hi\r")); err != nil {
t.Fatalf("write stdin: %v", err)
}
select {
case input := <-gotInput:
if input.PaneID != "p1" || string(input.Bytes) != "echo hi\r" {
t.Fatalf("input = %#v", input)
}
case <-time.After(3 * time.Second):
t.Fatalf("client did not forward input")
}
cancel()
_ = inW.Close()
select {
case err := <-runCh:
if err != nil {
t.Fatalf("client run: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatalf("client did not stop")
}
if err := <-errCh; err != nil && err != protocol.ErrTransportClosed {
t.Fatalf("daemon side: %v", err)
}
}
type lockedBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.Write(p)
}
func (b *lockedBuffer) Bytes() []byte {
b.mu.Lock()
defer b.mu.Unlock()
return append([]byte(nil), b.b.Bytes()...)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.String()
}
func ioPipe(t *testing.T) (*io.PipeReader, *io.PipeWriter) {
t.Helper()
r, w := io.Pipe()
return r, w
}
func sendTestFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
t.Helper()
f, err := protocol.NewFrame(typ, payload)
if err != nil {
t.Fatalf("frame %s: %v", typ, err)
}
if err := tr.Send(f); err != nil {
t.Fatalf("send %s: %v", typ, err)
}
}
func mustMarshalTest(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}

View File

@@ -1,135 +0,0 @@
package app
import (
"encoding/json"
"sync"
"github.com/hjbdev/patterm/internal/protocol"
)
const defaultClientSubscriberQueue = 256
// clientSubscriber is the daemon-to-client event bridge. Unlike daemon-local
// listeners such as timers, debug capture, and waiters, it never blocks the PTY
// pump: PTY chunks are copied before enqueue, and overflow marks the pane as
// needing a fresh snapshot.
type clientSubscriber struct {
projectKey string
project *Project
clientID string
frames chan protocol.Frame
mu sync.Mutex
snapshotRequired map[string]bool
lifecycleDirty bool
}
func newClientSubscriber(project *Project, clientID string, size int) *clientSubscriber {
if size <= 0 {
size = defaultClientSubscriberQueue
}
projectKey := ""
if project != nil {
projectKey = project.Key
}
return &clientSubscriber{
projectKey: projectKey,
project: project,
clientID: clientID,
frames: make(chan protocol.Frame, size),
snapshotRequired: make(map[string]bool),
lifecycleDirty: false,
}
}
func (s *clientSubscriber) Recv() (protocol.Frame, bool) {
f, ok := <-s.frames
return f, ok
}
func (s *clientSubscriber) SnapshotRequired(childID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotRequired[childID]
}
func (s *clientSubscriber) OnChildSpawned(c *Child) {
s.sendLifecycle(protocol.LifecycleSpawned, c, "")
}
func (s *clientSubscriber) OnChildExited(c *Child) {
s.sendLifecycle(protocol.LifecycleExited, c, "")
}
func (s *clientSubscriber) OnChildClosed(id string) {
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: protocol.LifecycleClosed,
ProjectKey: s.projectKey,
ChildID: id,
})})
}
func (s *clientSubscriber) OnChildStateChanged(id string, state IdleState) {
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: protocol.LifecycleStateChanged,
ProjectKey: s.projectKey,
ChildID: id,
State: string(state),
})})
}
func (s *clientSubscriber) OnPTYOut(childID string, chunk []byte) {
cp := append([]byte(nil), chunk...)
var size protocol.Size
var ownerID string
if s.project != nil {
size, ownerID, _ = s.project.PaneDisplay(childID)
}
f, err := protocol.NewFrame(protocol.FramePaneChunk, protocol.PaneChunk{PaneID: childID, Bytes: cp, Size: size, DisplayOwner: ownerID == "" || ownerID == s.clientID})
if err != nil {
return
}
select {
case s.frames <- f:
default:
s.mu.Lock()
s.snapshotRequired[childID] = true
s.mu.Unlock()
}
}
func (s *clientSubscriber) sendLifecycle(kind protocol.LifecycleKind, c *Child, state string) {
var child json.RawMessage
if c != nil {
child = mustJSON(serializeChildModel(c))
}
childID := ""
if c != nil {
childID = c.ID
}
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: kind,
ProjectKey: s.projectKey,
ChildID: childID,
Child: child,
State: state,
})})
}
func (s *clientSubscriber) sendFrame(f protocol.Frame) {
select {
case s.frames <- f:
default:
s.mu.Lock()
s.lifecycleDirty = true
s.mu.Unlock()
}
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}

View File

@@ -1,32 +0,0 @@
package app
import (
"testing"
"github.com/hjbdev/patterm/internal/protocol"
)
func TestClientSubscriberCopiesChunksAndMarksSnapshotOnOverflow(t *testing.T) {
sub := newClientSubscriber(&Project{Key: "project"}, "client", 1)
chunk := []byte("first")
sub.OnPTYOut("p_123456", chunk)
chunk[0] = 'X'
f, ok := sub.Recv()
if !ok {
t.Fatalf("Recv closed")
}
payload, err := protocol.Decode[protocol.PaneChunk](f)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if string(payload.Bytes) != "first" {
t.Fatalf("payload retained pump buffer: %q", string(payload.Bytes))
}
sub.OnPTYOut("p_123456", []byte("queued"))
sub.OnPTYOut("p_123456", []byte("dropped"))
if !sub.SnapshotRequired("p_123456") {
t.Fatalf("overflow did not mark pane snapshot required")
}
}

View File

@@ -1,40 +0,0 @@
package app
// ClientView is the per-client UI cursor over daemon-owned project/process
// state. In loopback mode there is one view, owned by uiState; future network
// clients will each get their own copy.
type ClientView struct {
ID string
ProjectKey string
ProjectName string
FocusedID string
FocusedPad string
ActiveAgentID string
PadOffset int
PadOffsetName string
Cols uint16
Rows uint16
}
func (v *ClientView) FocusChild(id string) {
v.FocusedID = id
v.FocusedPad = ""
}
func (v *ClientView) FocusPad(name string) {
v.FocusedID = ""
v.FocusedPad = name
if v.PadOffsetName != name {
v.PadOffset = 0
v.PadOffsetName = name
}
}
func (v *ClientView) ClearPadFocus() {
v.FocusedPad = ""
}
func (v *ClientView) Resize(cols, rows uint16) {
v.Cols = cols
v.Rows = rows
}

View File

@@ -1,530 +0,0 @@
package app
import (
"context"
"fmt"
"path/filepath"
"sort"
"sync"
"syscall"
"time"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/projectkey"
"github.com/hjbdev/patterm/internal/protocol"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
)
type Project struct {
Key string
Dir string
Name string
Session *Session
Pads *scratchpad.Store
Trust *trust.Store
Persist *persist.Store
Launcher *Launcher
Host *toolHost
savedProcess []persist.Entry
displayMu sync.Mutex
displayOwners map[string]paneDisplayOwner
lastActive time.Time
}
type paneDisplayOwner struct {
ClientID string
Size protocol.Size
}
type projectSummary struct {
Key string
Dir string
Name string
TabCount int
IsCurrent bool
}
// ProjectRegistry is the daemon-owned project map. Phase 1 still runs in one
// local process, but every project already has isolated stores, session,
// launcher, and tool host so future clients can attach to different projects.
type ProjectRegistry struct {
mu sync.Mutex
projects map[string]*Project
defaultProjectKey string
presets preset.Set
settings settings
mcpSrv *mcp.Server
cols, rows uint16
}
func newProjectRegistry(presets preset.Set, settings settings, mcpSrv *mcp.Server, cols, rows uint16) *ProjectRegistry {
return &ProjectRegistry{
projects: make(map[string]*Project),
presets: presets,
settings: settings,
mcpSrv: mcpSrv,
cols: cols,
rows: rows,
}
}
func (r *ProjectRegistry) Open(ctx context.Context, dir string) (*Project, error) {
key, err := projectkey.Key(dir)
if err != nil {
return nil, err
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
r.mu.Lock()
if p := r.projects[key]; p != nil {
p.lastActive = time.Now()
r.mu.Unlock()
return p, nil
}
r.mu.Unlock()
pads, err := scratchpad.Open(key)
if err != nil {
return nil, fmt.Errorf("app: scratchpad init: %w", err)
}
trustStore, err := trust.Open(key)
if err != nil {
return nil, fmt.Errorf("app: trust init: %w", err)
}
persistStore, err := persist.Open(key)
if err != nil {
return nil, fmt.Errorf("app: persist init: %w", err)
}
sess := NewSession(abs, key)
savedProcesses := persistStore.List()
for _, e := range savedProcesses {
_ = persistStore.Remove(e.ID)
}
sess.SetPersistStore(persistStore)
socket := ""
if r.mcpSrv != nil {
socket = r.mcpSrv.Socket()
}
launcher := NewLauncher(sess, socket, r.cols, r.rows)
host := newToolHost(sess, pads, launcher, r.presets, trustStore, r.cols, r.rows)
go sess.runClassifier(ctx)
p := &Project{
Key: key,
Dir: abs,
Name: filepath.Base(abs),
Session: sess,
Pads: pads,
Trust: trustStore,
Persist: persistStore,
Launcher: launcher,
Host: host,
savedProcess: savedProcesses,
displayOwners: make(map[string]paneDisplayOwner),
lastActive: time.Now(),
}
r.mu.Lock()
if existing := r.projects[key]; existing != nil {
r.mu.Unlock()
sess.Shutdown()
return existing, nil
}
r.projects[key] = p
if r.defaultProjectKey == "" {
r.defaultProjectKey = key
}
r.mu.Unlock()
return p, nil
}
func (r *ProjectRegistry) Project(key string) *Project {
r.mu.Lock()
defer r.mu.Unlock()
return r.projects[key]
}
func (r *ProjectRegistry) Count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.projects)
}
func (r *ProjectRegistry) DefaultProject() *Project {
r.mu.Lock()
defer r.mu.Unlock()
return r.projects[r.defaultProjectKey]
}
func (p *Project) ClaimPaneDisplay(clientID, paneID string, size protocol.Size) (protocol.Size, bool) {
if p == nil || paneID == "" {
return size, true
}
if size.Cols == 0 || size.Rows == 0 {
size = protocol.Size{Cols: 80, Rows: 24}
}
p.displayMu.Lock()
if p.displayOwners == nil {
p.displayOwners = make(map[string]paneDisplayOwner)
}
owner, ok := p.displayOwners[paneID]
if !ok || owner.ClientID == "" || owner.ClientID == clientID {
p.displayOwners[paneID] = paneDisplayOwner{ClientID: clientID, Size: size}
p.displayMu.Unlock()
p.Session.ResizeChild(paneID, size.Cols, size.Rows)
return size, true
}
p.displayMu.Unlock()
return owner.Size, false
}
func (p *Project) ResizeClientDisplays(clientID string, size protocol.Size) {
if p == nil || size.Cols == 0 || size.Rows == 0 {
return
}
p.displayMu.Lock()
var panes []string
for paneID, owner := range p.displayOwners {
if owner.ClientID != clientID {
continue
}
owner.Size = size
p.displayOwners[paneID] = owner
panes = append(panes, paneID)
}
p.displayMu.Unlock()
for _, paneID := range panes {
p.Session.ResizeChild(paneID, size.Cols, size.Rows)
}
p.Launcher.SetSize(size.Cols, size.Rows)
p.Host.SetSize(size.Cols, size.Rows)
}
func (p *Project) ReleaseClientDisplays(clientID string) {
if p == nil {
return
}
p.displayMu.Lock()
for paneID, owner := range p.displayOwners {
if owner.ClientID == clientID {
delete(p.displayOwners, paneID)
}
}
p.displayMu.Unlock()
}
func (p *Project) PaneDisplay(paneID string) (protocol.Size, string, bool) {
if p == nil || paneID == "" {
return protocol.Size{}, "", false
}
p.displayMu.Lock()
defer p.displayMu.Unlock()
owner, ok := p.displayOwners[paneID]
return owner.Size, owner.ClientID, ok
}
func (r *ProjectRegistry) Shutdown() {
r.mu.Lock()
projects := make([]*Project, 0, len(r.projects))
for _, p := range r.projects {
projects = append(projects, p)
}
r.mu.Unlock()
for _, p := range projects {
p.Session.Shutdown()
}
}
func (r *ProjectRegistry) ResizeAll(cols, rows uint16) {
r.mu.Lock()
r.cols, r.rows = cols, rows
projects := make([]*Project, 0, len(r.projects))
for _, p := range r.projects {
projects = append(projects, p)
}
r.mu.Unlock()
for _, p := range projects {
p.Session.ResizeAll(cols, rows)
p.Launcher.SetSize(cols, rows)
p.Host.SetSize(cols, rows)
}
}
func (r *ProjectRegistry) Summaries(currentKey string) []projectSummary {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]projectSummary, 0, len(r.projects))
for _, p := range r.projects {
out = append(out, projectSummary{
Key: p.Key,
Dir: p.Dir,
Name: p.Name,
TabCount: len(runningTopLevels(p.Session.Children())),
IsCurrent: p.Key == currentKey,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].IsCurrent != out[j].IsCurrent {
return out[i].IsCurrent
}
return out[i].Name < out[j].Name
})
return out
}
func (r *ProjectRegistry) findProjectByChild(id string) (*Project, *Child) {
if id == "" {
return nil, nil
}
r.mu.Lock()
projects := make([]*Project, 0, len(r.projects))
for _, p := range r.projects {
projects = append(projects, p)
}
r.mu.Unlock()
for _, p := range projects {
if c := p.Session.FindChild(id); c != nil {
return p, c
}
}
return nil, nil
}
func (r *ProjectRegistry) projectForCaller(callerID string) *Project {
if p, _ := r.findProjectByChild(callerID); p != nil {
return p
}
r.mu.Lock()
defer r.mu.Unlock()
return r.projects[r.defaultProjectKey]
}
func (r *ProjectRegistry) hostForCaller(callerID string) *toolHost {
if p := r.projectForCaller(callerID); p != nil {
return p.Host
}
return nil
}
func (r *ProjectRegistry) hostForProcess(processID string) *toolHost {
if p, _ := r.findProjectByChild(processID); p != nil {
return p.Host
}
return nil
}
func (r *ProjectRegistry) ResolveCallerIdentity(identity string) string {
r.mu.Lock()
projects := make([]*Project, 0, len(r.projects))
for _, p := range r.projects {
projects = append(projects, p)
}
r.mu.Unlock()
for _, p := range projects {
if c := p.Session.FindChildByIdentity(identity); c != nil {
return c.ID
}
}
return ""
}
func (r *ProjectRegistry) CallerRole(processID string) mcp.CallerRole {
if h := r.hostForCaller(processID); h != nil {
return h.CallerRole(processID)
}
return mcp.RoleOrchestrator
}
func (r *ProjectRegistry) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.ProcessInfo, error) {
return r.hostForCaller(callerID).SpawnAgent(callerID, args)
}
func (r *ProjectRegistry) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp.ProcessInfo, error) {
return r.hostForCaller(callerID).SpawnProcess(callerID, args)
}
func (r *ProjectRegistry) StartProcess(callerID, processID string) (mcp.ProcessInfo, error) {
if h := r.hostForProcess(processID); h != nil {
return h.StartProcess(callerID, processID)
}
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) RestartProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
if h := r.hostForProcess(processID); h != nil {
return h.RestartProcess(callerID, processID, sig)
}
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) StopProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
if h := r.hostForProcess(processID); h != nil {
return h.StopProcess(callerID, processID, sig)
}
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) CloseProcess(callerID, processID string) error {
if h := r.hostForProcess(processID); h != nil {
return h.CloseProcess(callerID, processID)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) RenameProcess(callerID, processID, name string) error {
if h := r.hostForProcess(processID); h != nil {
return h.RenameProcess(callerID, processID, name)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) SelectProcess(callerID, processID string) error {
if h := r.hostForProcess(processID); h != nil {
return h.SelectProcess(callerID, processID)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) ListProcesses(callerID, kindFilter string) []mcp.ProcessInfo {
if h := r.hostForCaller(callerID); h != nil {
return h.ListProcesses(callerID, kindFilter)
}
return nil
}
func (r *ProjectRegistry) GetProcessStatus(callerID, processID string) (mcp.ProcessStatus, error) {
if h := r.hostForProcess(processID); h != nil {
return h.GetProcessStatus(callerID, processID)
}
return mcp.ProcessStatus{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
return r.hostForCaller(callerID).GetProjectStatus(callerID)
}
func (r *ProjectRegistry) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
if h := r.hostForProcess(processID); h != nil {
return h.GetProcessOutput(callerID, processID, mode, sinceOffset)
}
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
if h := r.hostForProcess(processID); h != nil {
return h.GetProcessRawOutput(callerID, processID, sinceOffset)
}
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
if h := r.hostForProcess(processID); h != nil {
return h.SearchOutput(callerID, processID, pattern, kind, limit)
}
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (bool, string, error) {
if h := r.hostForProcess(processID); h != nil {
return h.WaitForPattern(callerID, processID, pattern, timeoutSeconds, scope)
}
return false, "", mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
if h := r.hostForProcess(processID); h != nil {
return h.GetProcessPorts(callerID, processID)
}
return nil, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendInputResult, error) {
if h := r.hostForProcess(args.ProcessID); h != nil {
return h.SendInput(callerID, args)
}
return mcp.SendInputResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
func (r *ProjectRegistry) SendMessage(callerID, targetID, message string) error {
if h := r.hostForProcess(targetID); h != nil {
return h.SendMessage(callerID, targetID, message)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", targetID)
}
func (r *ProjectRegistry) RequestHumanAttention(callerID, processID, reason string) error {
if h := r.hostForProcess(processID); h != nil {
return h.RequestHumanAttention(callerID, processID, reason)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) TimerWait(callerID string, seconds float64, label string) (string, error) {
return r.hostForCaller(callerID).TimerWait(callerID, seconds, label)
}
func (r *ProjectRegistry) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
return r.hostForCaller(callerID).TimerSet(callerID, args)
}
func (r *ProjectRegistry) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
return r.hostForCaller(callerID).TimerFireWhenIdleAny(callerID, args)
}
func (r *ProjectRegistry) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
return r.hostForCaller(callerID).TimerFireWhenIdleAll(callerID, args)
}
func (r *ProjectRegistry) TimerCancel(callerID, id string) error {
return r.hostForCaller(callerID).TimerCancel(callerID, id)
}
func (r *ProjectRegistry) TimerPause(callerID, id string) error {
return r.hostForCaller(callerID).TimerPause(callerID, id)
}
func (r *ProjectRegistry) TimerResume(callerID, id string) error {
return r.hostForCaller(callerID).TimerResume(callerID, id)
}
func (r *ProjectRegistry) TimerList(callerID string) ([]mcp.TimerInfo, error) {
return r.hostForCaller(callerID).TimerList(callerID)
}
func (r *ProjectRegistry) ScratchpadList(callerID string) ([]scratchpad.Entry, error) {
return r.hostForCaller(callerID).ScratchpadList(callerID)
}
func (r *ProjectRegistry) ScratchpadRead(callerID, name string) (string, string, error) {
return r.hostForCaller(callerID).ScratchpadRead(callerID, name)
}
func (r *ProjectRegistry) ScratchpadWrite(callerID, name, content, expectedRevision string) (string, error) {
return r.hostForCaller(callerID).ScratchpadWrite(callerID, name, content, expectedRevision)
}
func (r *ProjectRegistry) ScratchpadAppend(callerID, name, content string) error {
return r.hostForCaller(callerID).ScratchpadAppend(callerID, name, content)
}
func (r *ProjectRegistry) ScratchpadDelete(callerID, name string) error {
return r.hostForCaller(callerID).ScratchpadDelete(callerID, name)
}
func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI {
return r.hostForCaller(callerID).WhoAmI(callerID)
}
func (r *ProjectRegistry) Help(callerID, topic string) mcp.HelpResponse {
return r.hostForCaller(callerID).Help(callerID, topic)
}

View File

@@ -1,481 +0,0 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/protocol"
)
type DaemonOptions struct {
ProjectDir string
SocketPath string
PidPath string
ListenAddr string
Token string
TokenOut io.Writer
ListenReady chan string
Cols uint16
Rows uint16
}
type DaemonStatus struct {
PID int
Socket string
Projects []protocol.Project
}
func RuntimeDaemonPaths() (socketPath, pidPath string, err error) {
base := os.Getenv("XDG_RUNTIME_DIR")
if base == "" {
base = os.TempDir()
}
dir := filepath.Join(base, "patterm")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", err
}
return filepath.Join(dir, "daemon.sock"), filepath.Join(dir, "daemon.pid"), nil
}
func RunDaemon(ctx context.Context, opts DaemonOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return err
}
opts.ProjectDir = cwd
}
if opts.SocketPath == "" || opts.PidPath == "" {
socket, pid, err := RuntimeDaemonPaths()
if err != nil {
return err
}
if opts.SocketPath == "" {
opts.SocketPath = socket
}
if opts.PidPath == "" {
opts.PidPath = pid
}
}
if opts.Cols == 0 {
opts.Cols = 80
}
if opts.Rows == 0 {
opts.Rows = 24
}
lockPath, err := prepareDaemonSocket(opts.SocketPath, opts.PidPath)
if err != nil {
return err
}
defer os.Remove(lockPath)
ln, err := net.Listen("unix", opts.SocketPath)
if err != nil {
return fmt.Errorf("daemon: listen %s: %w", opts.SocketPath, err)
}
defer ln.Close()
defer os.Remove(opts.SocketPath)
if err := os.Chmod(opts.SocketPath, 0o600); err != nil {
return err
}
if err := os.WriteFile(opts.PidPath, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
return err
}
defer os.Remove(opts.PidPath)
presets, err := preset.Load()
if err != nil {
return fmt.Errorf("daemon: load presets: %w", err)
}
appSettings, _, err := loadSettings()
if err != nil {
logf("daemon settings load: %v", err)
}
mcpSrv, err := mcp.Start()
if err != nil {
return fmt.Errorf("daemon: mcp start: %w", err)
}
defer mcpSrv.Close()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
registry := newProjectRegistry(presets, appSettings, mcpSrv, opts.Cols, opts.Rows)
defer registry.Shutdown()
mcpSrv.SetHost(registry)
if _, err := registry.Open(ctx, opts.ProjectDir); err != nil {
return err
}
var tcpLn net.Listener
tcpToken := opts.Token
if opts.ListenAddr != "" {
addr := normalizeListenAddr(opts.ListenAddr)
tcpToken, err = ensureDaemonToken(tcpToken)
if err != nil {
return err
}
tcpLn, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("daemon: listen tcp %s: %w", addr, err)
}
defer tcpLn.Close()
if opts.ListenReady != nil {
select {
case opts.ListenReady <- tcpLn.Addr().String():
default:
}
}
out := opts.TokenOut
if out == nil {
out = os.Stderr
}
fmt.Fprintf(out, "patterm daemon listening on %s\npatterm token: %s\n", tcpLn.Addr().String(), tcpToken)
}
var wg sync.WaitGroup
go func() {
<-ctx.Done()
_ = ln.Close()
if tcpLn != nil {
_ = tcpLn.Close()
}
}()
errCh := make(chan error, 2)
go acceptDaemonLoop(ctx, &wg, ln, "", cancel, registry, errCh)
if tcpLn != nil {
go acceptDaemonLoop(ctx, &wg, tcpLn, tcpToken, cancel, registry, errCh)
}
select {
case <-ctx.Done():
case err := <-errCh:
cancel()
wg.Wait()
return err
}
wg.Wait()
return nil
}
func acceptDaemonLoop(ctx context.Context, wg *sync.WaitGroup, ln net.Listener, authToken string, stop func(), registry *ProjectRegistry, errCh chan<- error) {
for {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
return
}
select {
case errCh <- err:
default:
}
return
}
wg.Add(1)
go func() {
defer wg.Done()
handleDaemonConn(ctx, stop, registry, protocol.NewConnTransport(conn), authToken)
}()
}
}
func normalizeListenAddr(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return ""
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.HasPrefix(addr, ":") {
return addr
}
if _, err := strconv.Atoi(addr); err == nil {
return ":" + addr
}
return addr
}
func ensureDaemonToken(token string) (string, error) {
if strings.TrimSpace(token) != "" {
return strings.TrimSpace(token), nil
}
return LoadOrCreateClientToken()
}
func prepareDaemonSocket(socketPath, pidPath string) (string, error) {
if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
return "", err
}
lockPath := pidPath + ".lock"
if data, err := os.ReadFile(pidPath); err == nil {
if pid, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && pid > 0 {
if sigErr := syscallSignal0(pid); sigErr == nil {
return "", fmt.Errorf("daemon already running with pid %d", pid)
}
}
}
_ = os.Remove(socketPath)
_ = os.Remove(pidPath)
_ = os.Remove(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return "", fmt.Errorf("daemon: lock %s: %w", lockPath, err)
}
_, _ = f.WriteString(strconv.Itoa(os.Getpid()) + "\n")
_ = f.Close()
return lockPath, nil
}
func syscallSignal0(pid int) error {
return syscall.Kill(pid, 0)
}
func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport, authToken string) {
defer t.Close()
f, err := t.Recv()
if err != nil {
return
}
switch f.Type {
case protocol.FrameList:
_ = sendProjectList(t, registry, "")
return
case protocol.FrameStop:
_ = sendProjectList(t, registry, "")
stop()
return
case protocol.FrameAttach:
if authToken != "" {
attach, err := protocol.Decode[protocol.Attach](f)
if err != nil {
_ = sendProtocolError(t, err.Error())
return
}
if attach.Token != authToken {
_ = sendProtocolError(t, "auth denied")
return
}
}
handleDaemonAttach(ctx, registry, t, f)
default:
_ = sendProtocolError(t, fmt.Sprintf("first frame must be attach, list, or stop; got %q", f.Type))
}
}
func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protocol.Transport, first protocol.Frame) {
attach, err := protocol.Decode[protocol.Attach](first)
if err != nil {
_ = sendProtocolError(t, err.Error())
return
}
project := registry.Project(attach.ProjectKey)
if project == nil && attach.ProjectPath != "" {
project, err = registry.Open(ctx, attach.ProjectPath)
if err != nil {
_ = sendProtocolError(t, err.Error())
return
}
}
if project == nil {
project = registry.DefaultProject()
}
if project == nil {
_ = sendProtocolError(t, "no project open")
return
}
clientID := fmt.Sprintf("c-%d", time.Now().UnixNano())
view := ClientView{
ID: clientID,
ProjectKey: project.Key,
ProjectName: project.Name,
Cols: attach.TermSize.Cols,
Rows: attach.TermSize.Rows,
}
if child := firstRunningTopLevel(project.Session.Children()); child != nil {
view.FocusChild(child.ID)
project.ClaimPaneDisplay(clientID, child.ID, attach.TermSize)
}
sub := newClientSubscriber(project, clientID, defaultClientSubscriberQueue)
project.Session.SubscribeClient(sub)
defer project.Session.UnsubscribeClient(sub)
defer project.ReleaseClientDisplays(clientID)
_ = sendHello(t, project, view.ID)
_ = sendProjectList(t, registry, project.Key)
_ = sendChrome(t, project, view)
if view.FocusedID != "" {
_ = sendSnapshot(t, project, clientID, view.FocusedID)
}
// Close the transport when the daemon context is cancelled (shutdown or
// `daemon stop`). Without this the t.Recv() loop below blocks forever on a
// still-connected client and the accept loop's wg.Wait() never returns.
go func() {
<-ctx.Done()
_ = t.Close()
}()
done := make(chan struct{})
go func() {
defer close(done)
for {
f, ok := sub.Recv()
if !ok {
return
}
if err := t.Send(f); err != nil {
return
}
}
}()
for {
f, err := t.Recv()
if err != nil {
return
}
switch f.Type {
case protocol.FrameDetach:
return
case protocol.FrameInput:
msg, err := protocol.Decode[protocol.Input](f)
if err == nil {
if c := project.Session.FindChild(msg.PaneID); c != nil {
_ = c.InjectAsUser(msg.Bytes)
}
}
case protocol.FrameResize:
msg, err := protocol.Decode[protocol.Resize](f)
if err == nil {
view.Resize(msg.Size.Cols, msg.Size.Rows)
if view.FocusedID != "" {
if _, _, ok := project.PaneDisplay(view.FocusedID); !ok {
project.ClaimPaneDisplay(clientID, view.FocusedID, msg.Size)
}
}
project.ResizeClientDisplays(clientID, msg.Size)
}
case protocol.FrameFocus:
msg, err := protocol.Decode[protocol.Focus](f)
if err == nil && msg.PaneID != "" {
view.FocusChild(msg.PaneID)
project.ClaimPaneDisplay(clientID, msg.PaneID, protocol.Size{Cols: view.Cols, Rows: view.Rows})
_ = sendChrome(t, project, view)
_ = sendSnapshot(t, project, clientID, msg.PaneID)
}
case protocol.FramePaletteCommand:
if child := handleDaemonPaletteCommand(project, f); child != nil {
view.FocusChild(child.ID)
project.ClaimPaneDisplay(clientID, child.ID, protocol.Size{Cols: view.Cols, Rows: view.Rows})
_ = sendChrome(t, project, view)
_ = sendSnapshot(t, project, clientID, child.ID)
}
}
select {
case <-done:
return
default:
}
}
}
func handleDaemonPaletteCommand(project *Project, f protocol.Frame) *Child {
msg, err := protocol.Decode[protocol.PaletteCommand](f)
if err != nil {
return nil
}
switch msg.Kind {
case "spawn_command":
var p struct {
Argv []string `json:"argv"`
Name string `json:"name"`
WorkDir string `json:"working_dir"`
Shell bool `json:"shell"`
}
if err := json.Unmarshal(msg.Data, &p); err != nil || len(p.Argv) == 0 {
return nil
}
name := p.Name
if name == "" {
name = strings.Join(p.Argv, " ")
}
c, err := project.Launcher.LaunchCommandArgv(p.Argv, name, "", p.WorkDir, nil, p.Shell)
if err != nil {
return nil
}
return c
}
return nil
}
func sendHello(t protocol.Transport, p *Project, clientID string) error {
f, err := protocol.NewFrame(protocol.FrameHello, protocol.Hello{Version: 1, DaemonID: strconv.Itoa(os.Getpid()), ClientID: clientID, ProjectKey: p.Key})
if err != nil {
return err
}
return t.Send(f)
}
func sendProjectList(t protocol.Transport, registry *ProjectRegistry, current string) error {
summaries := registry.Summaries(current)
projects := make([]protocol.Project, 0, len(summaries))
for _, p := range summaries {
projects = append(projects, protocol.Project{Key: p.Key, Path: p.Dir, Name: p.Name, TabCount: p.TabCount})
}
f, err := protocol.NewFrame(protocol.FrameProjectList, protocol.ProjectList{Projects: projects})
if err != nil {
return err
}
return t.Send(f)
}
func sendChrome(t protocol.Transport, p *Project, view ClientView) error {
pads, _ := p.Pads.List()
model := buildChromeModel(p.Key, view, p.Session.Children(), pads)
b, err := json.Marshal(model)
if err != nil {
return err
}
f, err := protocol.NewFrame(protocol.FrameChrome, protocol.Chrome{ProjectKey: p.Key, Model: b})
if err != nil {
return err
}
return t.Send(f)
}
func sendSnapshot(t protocol.Transport, p *Project, clientID, paneID string) error {
b, err := p.Session.SerializeChild(paneID)
if err != nil {
return nil
}
size, ownerID, _ := p.PaneDisplay(paneID)
f, err := protocol.NewFrame(protocol.FramePaneSnapshot, protocol.PaneSnapshot{
PaneID: paneID,
Bytes: b,
Size: size,
DisplayOwner: ownerID == "" || ownerID == clientID,
})
if err != nil {
return err
}
return t.Send(f)
}
func sendProtocolError(t protocol.Transport, msg string) error {
f, err := protocol.NewFrame(protocol.FrameError, protocol.Error{Message: msg})
if err != nil {
return err
}
return t.Send(f)
}

View File

@@ -1,477 +0,0 @@
package app
import (
"context"
"encoding/json"
"io"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/protocol"
)
func TestDaemonDetachReattachPreservesProcess(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data"))
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
projectDir := filepath.Join(root, "project")
if err := os.MkdirAll(projectDir, 0o700); err != nil {
t.Fatalf("mkdir project: %v", err)
}
socket := filepath.Join(root, "runtime", "patterm", "daemon.sock")
pid := filepath.Join(root, "runtime", "patterm", "daemon.pid")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- RunDaemon(ctx, DaemonOptions{
ProjectDir: projectDir,
SocketPath: socket,
PidPath: pid,
Cols: 80,
Rows: 24,
})
}()
waitForSocket(t, socket, errCh)
client1 := dialDaemon(t, socket)
sendFrame(t, client1, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, client1, protocol.FrameHello)
expectFrame(t, client1, protocol.FrameProjectList)
expectFrame(t, client1, protocol.FrameChrome)
data, _ := json.Marshal(map[string]any{
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; while :; do echo STILL-HERE; sleep 1; done"},
"name": "survivor",
})
sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
waitForLifecycle(t, client1, protocol.LifecycleSpawned, 3*time.Second)
sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{})
_ = client1.Close()
client2 := dialDaemon(t, socket)
defer client2.Close()
sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, client2, protocol.FrameHello)
expectFrame(t, client2, protocol.FrameProjectList)
chrome := expectChrome(t, client2)
if !chromeHasProcess(chrome, "survivor") {
t.Fatalf("reattached chrome did not include surviving process: %s", string(chrome.Model))
}
expectFrame(t, client2, protocol.FramePaneSnapshot)
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("daemon returned error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatalf("daemon did not stop")
}
}
func TestDaemonTCPTokenAuthAndUnixExemption(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data"))
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
projectDir := filepath.Join(root, "project")
if err := os.MkdirAll(projectDir, 0o700); err != nil {
t.Fatalf("mkdir project: %v", err)
}
socket := filepath.Join(root, "runtime", "patterm", "daemon.sock")
pid := filepath.Join(root, "runtime", "patterm", "daemon.pid")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
ready := make(chan string, 1)
go func() {
errCh <- RunDaemon(ctx, DaemonOptions{
ProjectDir: projectDir,
SocketPath: socket,
PidPath: pid,
ListenAddr: "127.0.0.1:0",
Token: "secret-token",
TokenOut: io.Discard,
ListenReady: ready,
Cols: 80,
Rows: 24,
})
}()
waitForSocket(t, socket, errCh)
tcpAddr := waitForTCPAddr(t, ready, errCh)
assertTCPAttachDenied(t, tcpAddr, "")
assertTCPAttachDenied(t, tcpAddr, "wrong-token")
tcpClient := dialTCPDaemon(t, tcpAddr)
defer tcpClient.Close()
sendFrame(t, tcpClient, protocol.FrameAttach, protocol.Attach{
Token: "secret-token",
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, tcpClient, protocol.FrameHello)
expectFrame(t, tcpClient, protocol.FrameProjectList)
expectFrame(t, tcpClient, protocol.FrameChrome)
data, _ := json.Marshal(map[string]any{
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; echo TCP-SNAPSHOT; sleep 30"},
"name": "tcp-survivor",
})
sendFrame(t, tcpClient, protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
expectFrame(t, tcpClient, protocol.FramePaneSnapshot)
unixClient := dialDaemon(t, socket)
defer unixClient.Close()
sendFrame(t, unixClient, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, unixClient, protocol.FrameHello)
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("daemon returned error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatalf("daemon did not stop")
}
}
func TestDaemonPaneDisplayOwnerSizing(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
projectDir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24)
defer reg.Shutdown()
project, err := reg.Open(ctx, projectDir)
if err != nil {
t.Fatalf("open project: %v", err)
}
client1, daemon1 := protocol.NewLoopbackPair()
go handleDaemonConn(ctx, cancel, reg, daemon1, "")
sendFrame(t, client1, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, client1, protocol.FrameHello)
expectFrame(t, client1, protocol.FrameProjectList)
expectFrame(t, client1, protocol.FrameChrome)
data, _ := json.Marshal(map[string]any{
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
"name": "owner-pane",
})
sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
paneID := waitForLifecycleID(t, client1, protocol.LifecycleSpawned, 3*time.Second)
snap1 := waitForSnapshot(t, client1, paneID, 3*time.Second)
if !snap1.DisplayOwner || snap1.Size != (protocol.Size{Cols: 80, Rows: 24}) {
t.Fatalf("owner snapshot = owner:%v size:%+v, want owner true size 80x24", snap1.DisplayOwner, snap1.Size)
}
waitForEmulatorSize(t, project, paneID, 80, 24)
client2, daemon2 := protocol.NewLoopbackPair()
go handleDaemonConn(ctx, cancel, reg, daemon2, "")
sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 100, Rows: 30},
})
expectFrame(t, client2, protocol.FrameHello)
expectFrame(t, client2, protocol.FrameProjectList)
expectFrame(t, client2, protocol.FrameChrome)
snap2 := waitForSnapshot(t, client2, paneID, 3*time.Second)
if snap2.DisplayOwner || snap2.Size != (protocol.Size{Cols: 80, Rows: 24}) {
t.Fatalf("viewer snapshot = owner:%v size:%+v, want owner false size 80x24", snap2.DisplayOwner, snap2.Size)
}
sendFrame(t, client2, protocol.FrameResize, protocol.Resize{Size: protocol.Size{Cols: 100, Rows: 30}})
time.Sleep(100 * time.Millisecond)
waitForEmulatorSize(t, project, paneID, 80, 24)
sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{})
_ = client1.Close()
time.Sleep(100 * time.Millisecond)
sendFrame(t, client2, protocol.FrameFocus, protocol.Focus{PaneID: paneID})
snap3 := waitForSnapshot(t, client2, paneID, 3*time.Second)
if !snap3.DisplayOwner || snap3.Size != (protocol.Size{Cols: 100, Rows: 30}) {
t.Fatalf("claimed snapshot = owner:%v size:%+v, want owner true size 100x30", snap3.DisplayOwner, snap3.Size)
}
waitForEmulatorSize(t, project, paneID, 100, 30)
sendFrame(t, client2, protocol.FrameDetach, protocol.Detach{})
_ = client2.Close()
}
func waitForSocket(t *testing.T, socket string, errCh <-chan error) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(socket); err == nil {
return
}
select {
case err := <-errCh:
if err != nil && strings.Contains(err.Error(), "operation not permitted") {
t.Skipf("unix sockets unavailable in this sandbox: %v", err)
}
t.Fatalf("daemon exited before creating socket: %v", err)
default:
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("socket %s was not created", socket)
}
func dialDaemon(t *testing.T, socket string) protocol.Transport {
t.Helper()
conn, err := net.Dial("unix", socket)
if err != nil {
t.Fatalf("dial daemon: %v", err)
}
return protocol.NewConnTransport(conn)
}
func dialTCPDaemon(t *testing.T, addr string) protocol.Transport {
t.Helper()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("dial tcp daemon: %v", err)
}
return protocol.NewConnTransport(conn)
}
func waitForTCPAddr(t *testing.T, ready <-chan string, errCh <-chan error) string {
t.Helper()
select {
case addr := <-ready:
return addr
case err := <-errCh:
if err != nil && strings.Contains(err.Error(), "operation not permitted") {
t.Skipf("tcp sockets unavailable in this sandbox: %v", err)
}
t.Fatalf("daemon exited before TCP listener was ready: %v", err)
case <-time.After(3 * time.Second):
t.Fatalf("tcp listener was not ready")
}
return ""
}
func assertTCPAttachDenied(t *testing.T, addr, token string) {
t.Helper()
client := dialTCPDaemon(t, addr)
defer client.Close()
sendFrame(t, client, protocol.FrameAttach, protocol.Attach{
Token: token,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
f := expectFrame(t, client, protocol.FrameError)
msg, err := protocol.Decode[protocol.Error](f)
if err != nil {
t.Fatalf("decode error frame: %v", err)
}
if !strings.Contains(msg.Message, "auth denied") {
t.Fatalf("error message = %q, want auth denied", msg.Message)
}
}
func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
t.Helper()
f, err := protocol.NewFrame(typ, payload)
if err != nil {
t.Fatalf("frame %s: %v", typ, err)
}
if err := tr.Send(f); err != nil {
t.Fatalf("send %s: %v", typ, err)
}
}
func expectFrame(t *testing.T, tr protocol.Transport, typ protocol.FrameType) protocol.Frame {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
if !ok {
break
}
if err != nil {
t.Fatalf("recv %s: %v", typ, err)
}
if f.Type == typ {
return f
}
}
t.Fatalf("frame %s not received", typ)
return protocol.Frame{}
}
func expectChrome(t *testing.T, tr protocol.Transport) protocol.Chrome {
t.Helper()
f := expectFrame(t, tr, protocol.FrameChrome)
chrome, err := protocol.Decode[protocol.Chrome](f)
if err != nil {
t.Fatalf("decode chrome: %v", err)
}
return chrome
}
func waitForLifecycle(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
if !ok {
break
}
if err != nil {
t.Fatalf("recv lifecycle: %v", err)
}
if f.Type != protocol.FrameLifecycle {
continue
}
msg, err := protocol.Decode[protocol.Lifecycle](f)
if err != nil {
t.Fatalf("decode lifecycle: %v", err)
}
if msg.Kind == kind {
return
}
}
t.Fatalf("lifecycle %s not received", kind)
}
func waitForLifecycleID(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) string {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
if !ok {
break
}
if err != nil {
t.Fatalf("recv lifecycle: %v", err)
}
if f.Type != protocol.FrameLifecycle {
continue
}
msg, err := protocol.Decode[protocol.Lifecycle](f)
if err != nil {
t.Fatalf("decode lifecycle: %v", err)
}
if msg.Kind == kind {
return msg.ChildID
}
}
t.Fatalf("lifecycle %s not received", kind)
return ""
}
func waitForSnapshot(t *testing.T, tr protocol.Transport, paneID string, timeout time.Duration) protocol.PaneSnapshot {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
if !ok {
break
}
if err != nil {
t.Fatalf("recv snapshot: %v", err)
}
if f.Type != protocol.FramePaneSnapshot {
continue
}
msg, err := protocol.Decode[protocol.PaneSnapshot](f)
if err != nil {
t.Fatalf("decode snapshot: %v", err)
}
if msg.PaneID == paneID {
return msg
}
}
t.Fatalf("snapshot for %s not received", paneID)
return protocol.PaneSnapshot{}
}
func waitForEmulatorSize(t *testing.T, project *Project, paneID string, cols, rows uint16) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if c := project.Session.FindChild(paneID); c != nil {
if em := c.Emulator(); em != nil {
gotCols, gotRows := em.Size()
if gotCols == cols && gotRows == rows {
return
}
}
}
time.Sleep(25 * time.Millisecond)
}
if c := project.Session.FindChild(paneID); c != nil {
if em := c.Emulator(); em != nil {
gotCols, gotRows := em.Size()
t.Fatalf("emulator size = %dx%d, want %dx%d", gotCols, gotRows, cols, rows)
}
}
t.Fatalf("pane %s missing emulator", paneID)
}
func recvFrameWithin(tr protocol.Transport, timeout time.Duration) (protocol.Frame, error, bool) {
type result struct {
f protocol.Frame
err error
}
ch := make(chan result, 1)
go func() {
f, err := tr.Recv()
ch <- result{f: f, err: err}
}()
select {
case r := <-ch:
return r.f, r.err, true
case <-time.After(timeout):
return protocol.Frame{}, nil, false
}
}
func chromeHasProcess(chrome protocol.Chrome, name string) bool {
var model struct {
Processes []childModel `json:"processes"`
}
if err := json.Unmarshal(chrome.Model, &model); err != nil {
return false
}
for _, p := range model.Processes {
if p.Name == name {
return true
}
}
return false
}

View File

@@ -65,6 +65,17 @@ type toolHost struct {
timers *timerManager
}
const (
defaultMCPContentBytes = 12_000
maxMCPContentBytes = 65_536
defaultMCPCanonicalLines = 120
maxMCPCanonicalLines = 500
defaultMCPTailBytes = 8_000
defaultScratchpadReadBytes = 12_000
defaultSearchLineBytes = 2_000
maxSearchMatches = 50
)
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
h := &toolHost{
sess: sess,
@@ -353,8 +364,8 @@ func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStat
return st, nil
}
func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
caller := h.WhoAmI(callerID)
func (h *toolHost) GetProjectStatus(callerID string, includeTools bool) (mcp.ProjectStatus, error) {
caller := h.WhoAmI(callerID, includeTools)
processes := h.ListProcesses(callerID, "")
pads, _ := h.pads.List()
return mcp.ProjectStatus{
@@ -365,27 +376,48 @@ func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error)
}, nil
}
func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
func (h *toolHost) GetProcessOutput(callerID string, args mcp.ProcessOutputArgs) (mcp.ProcessOutput, error) {
processID, mode, sinceOffset := args.ProcessID, args.Mode, args.SinceOffset
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if mode == "" {
mode = "grid"
}
if args.Raw {
b, end := c.StreamRead(sinceOffset)
content, contentBytes, truncated, truncatedBytes := capBytesTail(b, capLimit(args.MaxBytes, defaultMCPContentBytes))
return mcp.ProcessOutput{
Content: content,
Mode: "stream",
NewOffset: end,
Status: string(c.Status()),
ContentBytes: contentBytes,
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil
}
out := mcp.ProcessOutput{
Mode: mode,
IdleMS: c.IdleMS(),
Status: string(c.Status()),
ScreenVersion: c.ScreenVersion(),
Canonicalized: true,
}
if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil {
out.ActiveScreen = activeScreenName(sc)
if args.IncludeMeta {
out.IdleMS = c.IdleMS()
out.ScreenVersion = c.ScreenVersion()
if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil {
out.ActiveScreen = activeScreenName(sc)
}
if cur, err := em.Cursor(); err == nil {
out.Cursor = &mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
}
cols, rows := em.Size()
out.Cols, out.Rows = int(cols), int(rows)
}
if cur, err := em.Cursor(); err == nil {
out.Cursor = mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
}
cols, rows := em.Size()
out.Cols, out.Rows = int(cols), int(rows)
}
maxLines := canonicalLineLimit(args.MaxLines)
switch mode {
case "grid":
em := c.Emulator()
@@ -399,11 +431,21 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
}
out.Content = normalizeGridText(txt)
content, lineTruncated, lineDroppedBytes := canonicalizeTerminalText(txt, maxLines)
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextMiddle(content, capLimit(args.MaxBytes, defaultMCPContentBytes))
if lineTruncated {
out.Truncated = true
out.TruncatedBytes += lineDroppedBytes
}
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
out.Content = string(stripANSIBytes(nil, b))
content, lineTruncated, lineDroppedBytes := canonicalizeTerminalText(string(b), maxLines)
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextTail(content, capLimit(args.MaxBytes, defaultMCPContentBytes))
if lineTruncated {
out.Truncated = true
out.TruncatedBytes += lineDroppedBytes
}
out.NewOffset = end
return out, nil
default:
@@ -411,34 +453,46 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
}
}
func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
c := h.sess.FindChild(processID)
func (h *toolHost) GetProcessRawOutput(callerID string, args mcp.RawOutputArgs) (mcp.RawOutput, error) {
c := h.sess.FindChild(args.ProcessID)
if c == nil {
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
b, end := c.StreamRead(sinceOffset)
b, end := c.StreamRead(args.SinceOffset)
content, contentBytes, truncated, truncatedBytes := capBytesTail(b, capLimit(args.MaxBytes, defaultMCPContentBytes))
return mcp.RawOutput{
Content: string(b),
NewOffset: end,
Status: string(c.Status()),
Content: content,
NewOffset: end,
Status: string(c.Status()),
ContentBytes: contentBytes,
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil
}
func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
c := h.sess.FindChild(processID)
func (h *toolHost) SearchOutput(callerID string, args mcp.SearchOutputArgs) (mcp.SearchResult, error) {
c := h.sess.FindChild(args.ProcessID)
if c == nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
re, err := regexp.Compile(pattern)
re, err := regexp.Compile(args.Pattern)
if err != nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
b, _ := c.StreamRead(0)
if kind == "rendered" {
if args.Kind == "rendered" {
b = stripANSIBytes(nil, b)
}
text := string(b)
lines := strings.Split(text, "\n")
limit := args.Limit
if limit <= 0 {
limit = 10
}
if limit > maxSearchMatches {
limit = maxSearchMatches
}
lineLimit := capLimit(args.MaxBytes, defaultSearchLineBytes)
matches := make([]mcp.SearchMatch, 0, limit)
truncated := false
for i, line := range lines {
@@ -447,6 +501,8 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
truncated = true
break
}
line, _, lineTruncated, _ := capTextTail(line, lineLimit)
truncated = truncated || lineTruncated
matches = append(matches, mcp.SearchMatch{LineNo: i + 1, Text: line})
}
}
@@ -588,6 +644,7 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
if err != nil {
return mcp.SendInputResult{}, err
}
tailSince := c.StreamOffset()
if err := c.InjectAsOrchestrator(payload); err != nil {
return mcp.SendInputResult{}, err
}
@@ -599,7 +656,12 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
}
if mode != "none" {
time.Sleep(time.Duration(args.WaitMS) * time.Millisecond)
tail, err := h.GetProcessOutput(callerID, args.ProcessID, mode, 0)
tail, err := h.GetProcessOutput(callerID, mcp.ProcessOutputArgs{
ProcessID: args.ProcessID,
Mode: mode,
SinceOffset: tailSince,
MaxBytes: capLimit(args.TailMaxBytes, defaultMCPTailBytes),
})
if err == nil {
res.Tail = &tail
}
@@ -811,13 +873,35 @@ func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
// Scratchpads / Meta
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadRead(_ string, name string) (string, string, error) {
return h.pads.Read(name)
func (h *toolHost) ScratchpadRead(args mcp.ScratchpadReadArgs) (mcp.ScratchpadReadResult, error) {
content, rev, err := h.pads.Read(args.Name)
if err != nil {
return mcp.ScratchpadReadResult{}, err
}
offset := args.Offset
if offset < 0 {
offset = 0
}
if offset > len(content) {
offset = len(content)
}
limited, contentBytes, truncated, truncatedBytes := capTextHead(content[offset:], capLimit(args.MaxBytes, defaultScratchpadReadBytes))
next := offset + contentBytes
return mcp.ScratchpadReadResult{
Content: limited,
Revision: rev,
Offset: offset,
NextOffset: next,
ContentBytes: contentBytes,
TotalBytes: len(content),
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil
}
func (h *toolHost) ScratchpadWrite(_, name, content, expectedRevision string) (string, error) {
func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
rev, err := h.pads.Write(name, content, expectedRevision)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
@@ -825,7 +909,7 @@ func (h *toolHost) ScratchpadWrite(_, name, content, expectedRevision string) (s
return rev, err
}
func (h *toolHost) ScratchpadAppend(_, name, content string) error {
func (h *toolHost) ScratchpadAppend(name, content string) error {
err := h.pads.Append(name, content)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
@@ -833,7 +917,7 @@ func (h *toolHost) ScratchpadAppend(_, name, content string) error {
return err
}
func (h *toolHost) ScratchpadDelete(_, name string) error {
func (h *toolHost) ScratchpadDelete(name string) error {
err := h.pads.Delete(name)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
@@ -841,7 +925,7 @@ func (h *toolHost) ScratchpadDelete(_, name string) error {
return err
}
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
func (h *toolHost) WhoAmI(callerID string, includeTools bool) mcp.WhoAmI {
w := mcp.WhoAmI{
ProcessID: callerID,
Role: h.CallerRole(callerID),
@@ -849,7 +933,9 @@ func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
Path: h.sess.projectDir,
Key: h.sess.projectKey,
},
AvailableTools: availableToolsForRole(h.CallerRole(callerID)),
}
if includeTools {
w.AvailableTools = availableToolsForRole(h.CallerRole(callerID))
}
if c := h.sess.FindChild(callerID); c != nil {
w.Name = c.DisplayName()
@@ -1009,11 +1095,10 @@ func activeScreenName(s pkgvt.Screen) string {
}
}
// ansiRegexp strips CSI escape sequences and common single-character
// controls (BEL, OSC terminators) from the stream. The vt emulator
// already handles full rendering for grid mode; this is only for
// stream-mode ANSI-stripped output.
var ansiRegexp = regexp.MustCompile(`\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
// ansiRegexp strips CSI/OSC escape sequences and common single-character
// controls from the stream. The vt emulator already handles full
// rendering for grid mode; this is only for stream-mode text output.
var ansiRegexp = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
@@ -1043,12 +1128,68 @@ func normalizeGridText(s string) string {
return strings.Join(out, "\n")
}
func capLimit(requested, def int) int {
if requested <= 0 {
requested = def
}
if requested > maxMCPContentBytes {
requested = maxMCPContentBytes
}
if requested < 0 {
return 0
}
return requested
}
func canonicalLineLimit(requested int) int {
if requested <= 0 {
return defaultMCPCanonicalLines
}
if requested > maxMCPCanonicalLines {
return maxMCPCanonicalLines
}
return requested
}
func capBytesTail(b []byte, limit int) (string, int, bool, int) {
if limit <= 0 || len(b) <= limit {
return string(b), len(b), false, 0
}
dropped := len(b) - limit
return string(b[dropped:]), limit, true, dropped
}
func capTextTail(s string, limit int) (string, int, bool, int) {
return capBytesTail([]byte(s), limit)
}
func capTextHead(s string, limit int) (string, int, bool, int) {
if limit <= 0 || len(s) <= limit {
return s, len(s), false, 0
}
return s[:limit], limit, true, len(s) - limit
}
func capTextMiddle(s string, limit int) (string, int, bool, int) {
if limit <= 0 || len(s) <= limit {
return s, len(s), false, 0
}
const marker = "\n...[truncated]...\n"
if limit <= len(marker)+2 {
return s[len(s)-limit:], limit, true, len(s) - limit
}
head := (limit - len(marker)) / 2
tail := limit - len(marker) - head
return s[:head] + marker + s[len(s)-tail:], limit, true, len(s) - limit
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a
// pattern match (WaitForPattern scrollback). Recognises the same
// shapes the regex did:
// - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR)
// - `\x1b] ... (BEL|ST)` (OSC)
// - `\x1b<final-byte>` for `@..._` (one-byte escapes)
// - `\x07` (BEL)
//
@@ -1078,6 +1219,24 @@ func stripANSIBytes(dst, src []byte) []byte {
continue
}
next := src[i+1]
if next == ']' {
j := i + 2
for j < len(src) {
if src[j] == 0x07 {
i = j + 1
break
}
if src[j] == 0x1b && j+1 < len(src) && src[j+1] == '\\' {
i = j + 2
break
}
j++
}
if j >= len(src) {
i = len(src)
}
continue
}
if next != '[' {
// One-byte ESC sequence (`\x1b<final>` where final is
// `@..._` per the regex; we drop anything that follows).
@@ -1160,7 +1319,7 @@ func helpFor(topic string) mcp.HelpResponse {
case "inspection":
return mcp.HelpResponse{
Topic: "inspection",
Content: "get_process_output gives you the visible pane (grid mode) or a byte slice from since_offset (stream mode). list_processes is for the whole session. get_project_status batches everything you need to orient yourself.",
Content: "get_process_output gives you canonical terminal text by default: the visible pane (grid mode) or recent stream text from since_offset (stream mode), with ANSI/control noise, borders, duplicate status churn, and volatile timers removed. Use raw:true only when you need diagnostic PTY bytes; include_meta:true restores cursor, geometry, and screen-version fields. list_processes is for the whole session. get_project_status batches everything you need to orient yourself.",
RelatedTools: []string{"list_processes", "get_process_status", "get_process_output", "search_output", "wait_for_pattern", "get_project_status"},
}
case "io":

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/scratchpad"
)
// mkChild builds a Child without starting a PTY. Use sparingly — the
@@ -134,6 +135,42 @@ func TestWrapSubAgentPromptEmptyStaysEmpty(t *testing.T) {
}
}
func TestMCPContentCapsPreferRecentStreamBytes(t *testing.T) {
got, gotBytes, truncated, dropped := capBytesTail([]byte("abcdefghijklmnop"), 6)
if got != "klmnop" || gotBytes != 6 || !truncated || dropped != 10 {
t.Fatalf("capBytesTail = (%q, %d, %v, %d)", got, gotBytes, truncated, dropped)
}
}
func TestMCPGridCapKeepsHeadAndTail(t *testing.T) {
got, gotBytes, truncated, dropped := capTextMiddle("abcdefghijklmnopqrstuvwxyz", 24)
if gotBytes != 24 || !truncated || dropped != 2 {
t.Fatalf("capTextMiddle metadata = (%d, %v, %d), content %q", gotBytes, truncated, dropped, got)
}
if !strings.Contains(got, "...[truncated]...") {
t.Fatalf("capTextMiddle missing marker: %q", got)
}
}
func TestScratchpadReadPagesLargeContent(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
store, err := scratchpad.Open("test-project")
if err != nil {
t.Fatalf("scratchpad open: %v", err)
}
if _, err := store.Write("notes.md", "abcdefghijklmnopqrstuvwxyz", ""); err != nil {
t.Fatalf("scratchpad write: %v", err)
}
h := &toolHost{pads: store}
res, err := h.ScratchpadRead(mcp.ScratchpadReadArgs{Name: "notes.md", Offset: 5, MaxBytes: 7})
if err != nil {
t.Fatalf("ScratchpadRead: %v", err)
}
if res.Content != "fghijkl" || !res.Truncated || res.NextOffset != 12 || res.TotalBytes != 26 {
t.Fatalf("ScratchpadRead result = %+v", res)
}
}
func TestHelpLifecycleTopicCoversCleanup(t *testing.T) {
resp := helpFor("lifecycle")
if resp.Topic != "lifecycle" {

View File

@@ -40,9 +40,6 @@ type paletteAction struct {
// For settings actions, the updated settings snapshot to persist.
settings *settings
projectKey string
projectPath string
}
// Group ids order the section bands the palette renders when no query
@@ -51,7 +48,6 @@ type paletteAction struct {
// an equally tight Spawn-section hit.
const (
groupFocused = iota
groupProject
groupOpen
groupSpawn
groupSettings
@@ -68,14 +64,6 @@ type paletteItem struct {
matches []int
}
type paletteProject struct {
Key string
Dir string
Name string
TabCount int
IsCurrent bool
}
// paletteMode toggles the palette between its fuzzy-picker UI and the
// freeform "spawn process" form. The form lives inside the palette so
// it shares the same modal-input contract (every byte intercepted; no
@@ -132,12 +120,10 @@ type paletteState struct {
items []paletteItem
mode paletteMode
form *spawnProcessForm
renameForm *renameForm
settingsInput *settingsInputForm
projects []paletteProject
currentProject string
mode paletteMode
form *spawnProcessForm
renameForm *renameForm
settingsInput *settingsInputForm
// showHelp swaps the item list for a static keybinding cheat-sheet
// until the next keystroke. Toggled by `?` in picker mode.
@@ -203,12 +189,6 @@ func newPalette(children []*Child, focused, focusedPad string, presets preset.Se
return p
}
func (p *paletteState) setProjects(current string, projects []paletteProject) {
p.currentProject = current
p.projects = append(p.projects[:0], projects...)
p.rebuild()
}
func (p *paletteState) rebuild() {
// Macro is resolved on the *original-case* query; the returned rest
// keeps the user's casing intact (useful when Tab cycles chips).
@@ -314,33 +294,7 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
}
}
if p.projects != nil {
// Group 1: Project — move the current client view without tearing
// down processes owned by the previous project.
for _, pr := range p.projects {
if pr.IsCurrent || pr.Key == p.currentProject {
continue
}
hint := pr.Dir
if pr.TabCount > 0 {
hint = fmt.Sprintf("%s · %d tabs", hint, pr.TabCount)
}
out = append(out, paletteItem{
label: "Switch project: " + pr.Name,
hint: hint,
action: paletteAction{kind: "project-switch", projectKey: pr.Key},
group: groupProject,
})
}
out = append(out, paletteItem{
label: "Open project…",
hint: "attach this client view to another local directory",
action: paletteAction{kind: "project-open-form"},
group: groupProject,
})
}
// Group 2: Open — switch entries for every running child *other than*
// Group 1: Open — switch entries for every running child *other than*
// the one already focused (no point offering a no-op switch). Dead
// agents are filtered out (no restart path); dead command processes
// remain so they can be restarted.
@@ -701,9 +655,6 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
p.cursor = 0
p.rebuildSettings()
return paletteAction{}, false, adv
case "project-open-form":
p.enterRenameForm("project", "", "", "project path")
return paletteAction{}, false, adv
case "pad-rename-form":
p.enterRenameForm("pad", a.padName, a.padName, "scratchpad: "+a.padName)
return paletteAction{}, false, adv
@@ -962,9 +913,6 @@ func (p *paletteState) submitRename() paletteAction {
return paletteAction{kind: "cancel"}
}
newName := strings.TrimSpace(string(p.renameForm.name))
if p.renameForm.subject == "project" {
return paletteAction{kind: "project-open-submit", projectPath: newName}
}
if newName == "" {
return paletteAction{kind: "cancel"}
}

View File

@@ -1,162 +0,0 @@
package app
import (
"context"
"syscall"
"testing"
"github.com/hjbdev/patterm/internal/preset"
)
func TestSwitchProjectPreservesProjectProcessTrees(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24)
defer reg.Shutdown()
projectA, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project A: %v", err)
}
projectB, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project B: %v", err)
}
a, err := projectA.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "a-loop",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project A command: %v", err)
}
b, err := projectB.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "b-loop",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project B command: %v", err)
}
t.Cleanup(func() {
_ = projectA.Session.Kill(a.ID, syscall.SIGTERM)
_ = projectB.Session.Kill(b.ID, syscall.SIGTERM)
})
waitUntilLive(t, a)
waitUntilLive(t, b)
st := &uiState{
registry: reg,
project: projectA,
sess: projectA.Session,
launcher: projectA.Launcher,
pads: projectA.Pads,
trust: projectA.Trust,
timers: projectA.Host.timers,
chromeWake: make(chan struct{}, 1),
view: ClientView{
ID: "test",
ProjectKey: projectA.Key,
ProjectName: projectA.Name,
Cols: 80,
Rows: 24,
},
}
st.focusChildLocked(a)
projectA.Session.Subscribe(st)
st.switchProject(projectB)
if st.view.ProjectKey != projectB.Key {
t.Fatalf("view project key = %q, want %q", st.view.ProjectKey, projectB.Key)
}
if st.sess != projectB.Session {
t.Fatalf("ui session did not move to project B")
}
if projectA.Session.FindChild(a.ID) == nil {
t.Fatalf("project A child disappeared after switch")
}
if projectB.Session.FindChild(b.ID) == nil {
t.Fatalf("project B child disappeared after switch")
}
if !a.IsLive() {
t.Fatalf("project A child stopped after switch")
}
if !b.IsLive() {
t.Fatalf("project B child stopped after switch")
}
st.switchProject(projectA)
if st.view.ProjectKey != projectA.Key {
t.Fatalf("view project key after switching back = %q, want %q", st.view.ProjectKey, projectA.Key)
}
if projectA.Session.FindChild(a.ID) == nil || projectB.Session.FindChild(b.ID) == nil {
t.Fatalf("switching back should preserve both project process trees")
}
}
func TestProjectRegistryScratchpadsRouteByCallerProject(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24)
defer reg.Shutdown()
projectA, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project A: %v", err)
}
projectB, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project B: %v", err)
}
a, err := projectA.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "a-caller",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project A caller: %v", err)
}
b, err := projectB.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "b-caller",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project B caller: %v", err)
}
t.Cleanup(func() {
_ = projectA.Session.Kill(a.ID, syscall.SIGTERM)
_ = projectB.Session.Kill(b.ID, syscall.SIGTERM)
})
waitUntilLive(t, a)
waitUntilLive(t, b)
if _, err := reg.ScratchpadWrite(a.ID, "note.md", "project A", ""); err != nil {
t.Fatalf("write project A scratchpad: %v", err)
}
if _, err := reg.ScratchpadWrite(b.ID, "note.md", "project B", ""); err != nil {
t.Fatalf("write project B scratchpad: %v", err)
}
gotA, _, err := reg.ScratchpadRead(a.ID, "note.md")
if err != nil {
t.Fatalf("read project A scratchpad: %v", err)
}
gotB, _, err := reg.ScratchpadRead(b.ID, "note.md")
if err != nil {
t.Fatalf("read project B scratchpad: %v", err)
}
if gotA != "project A" || gotB != "project B" {
t.Fatalf("scratchpad routing leaked between projects: A=%q B=%q", gotA, gotB)
}
}

View File

@@ -90,6 +90,8 @@ func TestStripANSIBytesEquivalence(t *testing.T) {
cases := []string{
"hello world",
"\x1b[31mred\x1b[0m text",
"\x1b]0;title\x07after osc",
"\x1b]2;title\x1b\\after st",
"line1\nline2\r\nline3",
"bell\x07ish",
"weird \x1bA escape",

View File

@@ -119,7 +119,7 @@ func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40)
host.scratch = recorder
if err := host.ScratchpadDelete("", "doomed.md"); err != nil {
if err := host.ScratchpadDelete("doomed.md"); err != nil {
t.Fatalf("ScratchpadDelete: %v", err)
}
if recorder.count != 1 {
@@ -128,7 +128,7 @@ func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
if _, _, err := pads.Read("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read deleted pad error = %v, want os.ErrNotExist", err)
}
if err := host.ScratchpadDelete("", "doomed.md"); !errors.Is(err, os.ErrNotExist) {
if err := host.ScratchpadDelete("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("delete missing error = %v, want os.ErrNotExist", err)
}
if recorder.count != 1 {

View File

@@ -46,13 +46,6 @@ type Session struct {
listenersMu sync.Mutex
listeners atomic.Pointer[[]ChildEventListener]
// clientListeners is the network-client subscriber path. These
// listeners must be non-blocking and copy PTY chunks before enqueueing;
// daemon-internal observers (timers, debug capture, waiters) stay on
// listeners above so backpressure policy is isolated to clients.
clientListenersMu sync.Mutex
clientListeners atomic.Pointer[[]ChildEventListener]
// persistStore records top-level command entries to a per-project
// JSON file so they can be re-spawned after patterm restarts.
// Optional; nil means "no persistence" (used by unit tests).
@@ -125,16 +118,6 @@ func (s *Session) Subscribe(l ChildEventListener) {
s.listeners.Store(&next)
}
func (s *Session) SubscribeClient(l ChildEventListener) {
s.clientListenersMu.Lock()
defer s.clientListenersMu.Unlock()
prev := s.clientListenersSnapshot()
next := make([]ChildEventListener, 0, len(prev)+1)
next = append(next, prev...)
next = append(next, l)
s.clientListeners.Store(&next)
}
// Unsubscribe removes a previously-registered listener. Safe to call
// with a listener that wasn't registered (no-op).
func (s *Session) Unsubscribe(l ChildEventListener) {
@@ -153,24 +136,6 @@ func (s *Session) Unsubscribe(l ChildEventListener) {
s.listeners.Store(&next)
}
// UnsubscribeClient removes a previously-registered network client listener.
// Safe to call with a listener that was never registered.
func (s *Session) UnsubscribeClient(l ChildEventListener) {
s.clientListenersMu.Lock()
defer s.clientListenersMu.Unlock()
prev := s.clientListenersSnapshot()
if len(prev) == 0 {
return
}
next := make([]ChildEventListener, 0, len(prev))
for _, e := range prev {
if e != l {
next = append(next, e)
}
}
s.clientListeners.Store(&next)
}
// listenersSnapshot returns the frozen listener slice. Safe to call
// without the listeners mutex.
func (s *Session) listenersSnapshot() []ChildEventListener {
@@ -181,30 +146,16 @@ func (s *Session) listenersSnapshot() []ChildEventListener {
return *p
}
func (s *Session) clientListenersSnapshot() []ChildEventListener {
p := s.clientListeners.Load()
if p == nil {
return nil
}
return *p
}
func (s *Session) emitSpawn(c *Child) {
for _, l := range s.listenersSnapshot() {
l.OnChildSpawned(c)
}
for _, l := range s.clientListenersSnapshot() {
l.OnChildSpawned(c)
}
}
func (s *Session) emitExit(c *Child) {
for _, l := range s.listenersSnapshot() {
l.OnChildExited(c)
}
for _, l := range s.clientListenersSnapshot() {
l.OnChildExited(c)
}
}
// emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners
@@ -214,27 +165,18 @@ func (s *Session) emitPTYOut(id string, chunk []byte) {
for _, l := range s.listenersSnapshot() {
l.OnPTYOut(id, chunk)
}
for _, l := range s.clientListenersSnapshot() {
l.OnPTYOut(id, chunk)
}
}
func (s *Session) emitStateChanged(id string, state IdleState) {
for _, l := range s.listenersSnapshot() {
l.OnChildStateChanged(id, state)
}
for _, l := range s.clientListenersSnapshot() {
l.OnChildStateChanged(id, state)
}
}
func (s *Session) emitClosed(id string) {
for _, l := range s.listenersSnapshot() {
l.OnChildClosed(id)
}
for _, l := range s.clientListenersSnapshot() {
l.OnChildClosed(id)
}
}
func (s *Session) ChildEnv() []string {
@@ -284,9 +226,6 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
if spec.Env == nil {
spec.Env = s.ChildEnv()
}
if spec.WorkDir == "" {
spec.WorkDir = s.projectDir
}
s.mu.Lock()
id := s.mintUniqueIDLocked()
@@ -742,22 +681,6 @@ func (s *Session) ResizeAll(cols, rows uint16) {
}
}
func (s *Session) ResizeChild(id string, cols, rows uint16) {
if cols == 0 || rows == 0 {
return
}
c := s.FindChild(id)
if c == nil {
return
}
if pty := c.PTY(); pty != nil {
_ = pty.Resize(cols, rows)
}
if em := c.Emulator(); em != nil {
_ = 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.

View File

@@ -561,14 +561,16 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
if t.status != timerStatusPending && t.status != timerStatusPaused {
continue
}
body, bodyTruncated := timerBodyPreview(t.body)
info := mcp.TimerInfo{
ID: t.id,
Label: t.label,
Body: t.body,
Kind: string(t.kind),
Status: t.status,
OwnerID: t.ownerID,
WatchedIDs: append([]string(nil), t.watched...),
ID: t.id,
Label: t.label,
Body: body,
BodyTruncated: bodyTruncated,
Kind: string(t.kind),
Status: t.status,
OwnerID: t.ownerID,
WatchedIDs: append([]string(nil), t.watched...),
}
if t.status == timerStatusPending && !t.firesAt.IsZero() {
info.FiresAtUnixMS = t.firesAt.UnixMilli()
@@ -581,6 +583,14 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
return out
}
func timerBodyPreview(body string) (string, bool) {
const max = 500
if len(body) <= max {
return body, false
}
return body[:max], true
}
// activeForChild returns the nearest pending or paused timer attached
// to child id (either owned by it or watching it). Used by the sidebar
// for the "⏱ 12s" indicator. nil when none.

View File

@@ -1,63 +0,0 @@
package app
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
)
func ClientTokenPath() (string, error) {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "patterm", "clients", "token"), nil
}
func LoadClientToken() (string, error) {
path, err := ClientTokenPath()
if err != nil {
return "", err
}
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
func LoadOrCreateClientToken() (string, error) {
if token, err := LoadClientToken(); err == nil && token != "" {
return token, nil
}
token, err := generateClientToken()
if err != nil {
return "", err
}
path, err := ClientTokenPath()
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil {
return "", err
}
return token, nil
}
func generateClientToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("token: random: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}

View File

@@ -23,9 +23,9 @@ func TestRestartRestoresUserCommandProcess(t *testing.T) {
}
sc := &Scenario{
Name: "restart_persist",
Cols: 120,
Rows: 40,
Name: "restart_persist",
Cols: 120,
Rows: 40,
Trust: []string{"persist-target"},
Presets: ScenarioPresets{
Processes: []ScenarioPreset{{
@@ -143,7 +143,7 @@ func openSession(t *testing.T, env *testEnv, childEnv []string) *Session {
if err != nil {
t.Fatalf("vt emulator: %v", err)
}
p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows)
if err != nil {
_ = em.Close()
t.Fatalf("pty start: %v", err)

View File

@@ -0,0 +1,62 @@
{
"name": "canonical_output_noise",
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {
"kind": "command",
"argv": [
"sh",
"-lc",
"printf '\\033[31mStatus: running 12s\\033[0m\\nStatus: running 13s\\n╭────╮\\n│ │\\nDownloading 10%%\\rDownloading 100%%\\nFINAL: deploy ready\\n'; sleep 5"
],
"name": "noisy"
},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {
"process_id": "{{proc.process_id}}",
"mode": "stream",
"raw": true,
"max_lines": 20
},
"path": "content",
"contains": "FINAL: deploy ready",
"timeout_ms": 5000,
"save_as": "raw"
},
{
"type": "assert_saved",
"from": "raw",
"path": "content",
"contains": "FINAL: deploy ready"
},
{
"type": "mcp_call",
"method": "get_process_output",
"params": {
"process_id": "{{proc.process_id}}",
"mode": "stream",
"since_offset": 0,
"max_lines": 20
},
"save_as": "canonical"
},
{
"type": "assert_saved",
"from": "canonical",
"path": "content",
"equals": "Status: running [time]\nDownloading [count]\nFINAL: deploy ready"
},
{
"type": "assert_saved",
"from": "canonical",
"path": "canonicalized",
"equals": true
}
]
}

View File

@@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) {
if err != nil {
return nil, err
}
p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows)
if err != nil {
_ = em.Close()
return nil, err

View File

@@ -188,9 +188,6 @@ func RunStdioProxy(socket, identity string) error {
// "<token>"} + newline. Real protocol handshake is a later
// milestone.
greeting := map[string]string{"patterm_identity": identity}
if key := os.Getenv("PATTERM_PROJECT_KEY"); key != "" {
greeting["project_key"] = key
}
gb, _ := json.Marshal(greeting)
gb = append(gb, '\n')
if _, err := conn.Write(gb); err != nil {

View File

@@ -134,16 +134,16 @@ func (h *blockingToolHost) ListProcesses(string, string) []ProcessInfo { return
func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) {
return ProcessStatus{ProcessInfo: ProcessInfo{ID: "p_fast", Status: "running"}}, nil
}
func (h *blockingToolHost) GetProjectStatus(string) (ProjectStatus, error) {
func (h *blockingToolHost) GetProjectStatus(string, bool) (ProjectStatus, error) {
return ProjectStatus{}, nil
}
func (h *blockingToolHost) GetProcessOutput(string, string, string, int64) (ProcessOutput, error) {
func (h *blockingToolHost) GetProcessOutput(string, ProcessOutputArgs) (ProcessOutput, error) {
return ProcessOutput{}, nil
}
func (h *blockingToolHost) GetProcessRawOutput(string, string, int64) (RawOutput, error) {
func (h *blockingToolHost) GetProcessRawOutput(string, RawOutputArgs) (RawOutput, error) {
return RawOutput{}, nil
}
func (h *blockingToolHost) SearchOutput(string, string, string, string, int) (SearchResult, error) {
func (h *blockingToolHost) SearchOutput(string, SearchOutputArgs) (SearchResult, error) {
return SearchResult{}, nil
}
func (h *blockingToolHost) WaitForPattern(string, string, string, float64, string) (bool, string, error) {
@@ -177,14 +177,14 @@ func (h *blockingToolHost) TimerResume(string, string) error { return nil }
func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) {
return nil, nil
}
func (h *blockingToolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return nil, nil }
func (h *blockingToolHost) ScratchpadRead(string, string) (string, string, error) {
return "", "", nil
func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil }
func (h *blockingToolHost) ScratchpadRead(ScratchpadReadArgs) (ScratchpadReadResult, error) {
return ScratchpadReadResult{}, nil
}
func (h *blockingToolHost) ScratchpadWrite(string, string, string, string) (string, error) {
func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) {
return "", nil
}
func (h *blockingToolHost) ScratchpadAppend(string, string, string) error { return nil }
func (h *blockingToolHost) ScratchpadDelete(string, string) error { return nil }
func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} }
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }
func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil }
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil }
func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} }
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }

View File

@@ -3,6 +3,8 @@ package mcp
import (
"encoding/json"
"fmt"
"github.com/hjbdev/patterm/internal/scratchpad"
)
// MCP protocol surface. The patterm server originally exposed each
@@ -43,7 +45,7 @@ var serverInfo = map[string]any{
// up as sub-agents and won't be tied into the patterm lifecycle.
//
// Keep this short — clients vary in how much they surface to the LLM.
const serverInstructions = "You are already running INSIDE patterm; the `patterm` MCP server is connected over the same stdio MCP transport you use for any other MCP server. Use the MCP tools you see in tools/list — do NOT (a) try to launch `patterm` or `patterm mcp-stdio` yourself, (b) poke the Unix socket through perl / nc / socat / curl, or (c) shell out to `claude` / `codex` / `opencode` to start a peer. Any of those bypasses caller-identity and the new agent will land as a stray top-level tab instead of a child under you. Start with `whoami` for your role and the full tool list, then `help('topics')` for orientation. `spawn_agent` is the only correct way to start a sub-agent; `spawn_process` is for non-LLM commands; `list_processes` / `get_process_output` inspect them; `send_input` / `send_message` drive them. Whatever you spawn is yours to `close_process` when done. When you `send_message` a sub-agent, its reply comes back into YOUR pane as `[sub-agent:<name>] …`, not into the sub-agent's output — to wait for it, use `timer_fire_when_idle_any([sub_agent])` and then read your own pane; do NOT `wait_for_pattern` on the sub-agent, that will deadlock until timeout."
const serverInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies."
// toolDescriptor is the shape returned by `tools/list`. inputSchema is
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
@@ -76,37 +78,41 @@ func objectSchema(properties map[string]any, required []string) map[string]any {
}
func stringProp(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
_ = desc
return map[string]any{"type": "string"}
}
func numberProp(desc string) map[string]any {
return map[string]any{"type": "number", "description": desc}
_ = desc
return map[string]any{"type": "number"}
}
func integerProp(desc string) map[string]any {
return map[string]any{"type": "integer", "description": desc}
_ = desc
return map[string]any{"type": "integer"}
}
func booleanProp(desc string) map[string]any {
return map[string]any{"type": "boolean", "description": desc}
_ = desc
return map[string]any{"type": "boolean"}
}
func arrayOfStringsProp(desc string) map[string]any {
_ = desc
return map[string]any{
"type": "array",
"description": desc,
"items": map[string]any{"type": "string"},
"type": "array",
"items": map[string]any{"type": "string"},
}
}
// toolCatalog is the full list advertised via tools/list. Descriptions
// are intentionally short — clients are expected to fetch help() for
// detail. Schemas mirror the param structs in tools.go.
func toolCatalog() []toolDescriptor {
return []toolDescriptor{
func toolCatalog(role CallerRole) []toolDescriptor {
tools := []toolDescriptor{
{
Name: "spawn_agent",
Description: "Spawn a sub-agent from an agent preset and optionally seed it with initial instructions. This is the ONLY correct way to start a sub-agent under you — do not shell out to `claude` / `codex` / `opencode` and do not poke patterm's Unix socket via perl / nc / socat. Either bypasses caller identity and the new agent lands as a stray top-level tab instead of your child. Caller owns lifecycle: when the sub-agent's work is done (it reports back via send_message, or you no longer need it), call close_process on its process_id to free the pane and tear down the PTY. See help('spawning') and help('lifecycle').",
Description: "Spawn a sub-agent from an agent preset.",
InputSchema: objectSchema(map[string]any{
"agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."),
"agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."),
@@ -115,14 +121,14 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "spawn_process",
Description: "Spawn a process: a terminal, a process preset, or a freeform argv command. Caller owns lifecycle: when the process is no longer needed, call close_process to remove its entry (live children are SIGKILL'd first). See help('lifecycle').",
Description: "Spawn a terminal, process preset, or argv command.",
InputSchema: objectSchema(map[string]any{
"kind": stringProp("\"terminal\" or \"command\"."),
"preset": stringProp("Process preset name (mutually exclusive with argv)."),
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Argv vector for freeform commands."},
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"name": stringProp("Display name for the pane."),
"working_dir": stringProp("Working directory for the spawned process."),
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "Extra environment variables."},
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
"shell": booleanProp("Run argv through sh -lc."),
}, nil),
},
@@ -188,23 +194,30 @@ func toolCatalog() []toolDescriptor {
{
Name: "get_project_status",
Description: "One-shot orientation: project, caller, processes, scratchpads.",
InputSchema: objectSchema(nil, nil),
InputSchema: objectSchema(map[string]any{
"include_tools": booleanProp("Include available_tools in caller metadata."),
}, nil),
},
{
Name: "get_process_output",
Description: "Read rendered grid (\"grid\") or ANSI-stripped stream (\"stream\") output, with screen-version watermark.",
Description: "Read canonical terminal text by default: visible grid (\"grid\") or recent stream (\"stream\") with ANSI/control noise, borders, duplicate status churn, and volatile timers removed. Set raw=true only for diagnostic ANSI-preserved PTY bytes.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"mode": stringProp("\"grid\" (default) or \"stream\"."),
"since_offset": integerProp("Watermark offset from a previous call."),
"max_bytes": integerProp("Maximum content bytes to return."),
"max_lines": integerProp("Maximum canonical lines to return (default 120, max 500)."),
"raw": booleanProp("Return raw ANSI-preserved stream bytes instead of canonical text."),
"include_meta": booleanProp("Include verbose cursor, geometry, active screen, idle, and screen-version metadata."),
}, []string{"process_id"}),
},
{
Name: "get_process_raw_output",
Description: "Read the raw ANSI byte stream since since_offset.",
Description: "Compatibility alias for raw=true get_process_output: read the raw ANSI byte stream since since_offset.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"since_offset": integerProp("Byte offset from a previous call."),
"max_bytes": integerProp("Maximum content bytes to return."),
}, []string{"process_id"}),
},
{
@@ -214,12 +227,13 @@ func toolCatalog() []toolDescriptor {
"process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."),
"kind": stringProp("\"rendered\" (default) or \"raw\"."),
"limit": integerProp("Max matches (default 20)."),
"limit": integerProp("Max matches (default 10)."),
"max_bytes": integerProp("Max bytes per returned match line."),
}, []string{"process_id", "pattern"}),
},
{
Name: "wait_for_pattern",
Description: "Block until pattern appears in the TARGET process's own output, or timeout elapses. Use this for waiting on text the target itself will emit (a shell prompt, a build's \"tests passed\" line, etc.). Anti-pattern: do NOT use this to wait for a sub-agent's reply to send_message — replies are routed into the CALLER's pane tagged `[sub-agent:<name>]`, not into the sub-agent's output, so this call will spin to timeout. For sub-agent coordination use `timer_fire_when_idle_any` and then read your own pane.",
Description: "Block until pattern appears in the target process output.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."),
@@ -238,18 +252,19 @@ func toolCatalog() []toolDescriptor {
Name: "send_input",
Description: "Type text, paste a block, or fire a named key into a process. Optional tail-after-send.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
"text": stringProp("Text payload for kind=text/paste."),
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
"submit": booleanProp("Whether to append a submit keystroke."),
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
"process_id": stringProp("Target process id."),
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
"text": stringProp("Text payload for kind=text/paste."),
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
"submit": booleanProp("Whether to append a submit keystroke."),
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
"tail_max_bytes": integerProp("Maximum bytes in returned tail."),
}, []string{"process_id", "kind"}),
},
{
Name: "send_message",
Description: "Deliver a text message to another process as orchestrator-owned input. Fire-and-forget: returns immediately, without waiting for the recipient to read or act. If the recipient replies via send_message, that reply arrives in YOUR pane tagged `[sub-agent:<name>]` (child→parent) or `[orchestrator]` (parent→child) — NOT in the recipient's output. To wait for a sub-agent's reply, schedule `timer_fire_when_idle_any([sub_agent_id], body=…)` and then read your own pane when the timer fires. Do not `wait_for_pattern` on the recipient for a reply; it will deadlock.",
Description: "Send a tagged message to a parent or child process.",
InputSchema: objectSchema(map[string]any{
"target_process_id": stringProp("Recipient process id."),
"message": stringProp("Message body."),
@@ -283,7 +298,7 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "timer_fire_when_idle_any",
Description: "Canonical way to wait for a sub-agent to finish working: send_message the sub-agent, then schedule this with watched=[sub_agent_id]; when it fires, the reply is already sitting in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
Description: "Fire when any watched process becomes idle.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
@@ -294,7 +309,7 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "timer_fire_when_idle_all",
Description: "Canonical way to wait for several sub-agents to finish working in parallel: send_message each one, then schedule this with watched=[…ids]; when it fires, each reply is in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
Description: "Fire when all watched processes are idle.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
@@ -338,7 +353,9 @@ func toolCatalog() []toolDescriptor {
Name: "scratchpad_read",
Description: "Read a scratchpad entry, returning content and revision.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
"name": stringProp("Scratchpad name."),
"offset": integerProp("Byte offset to start reading."),
"max_bytes": integerProp("Maximum content bytes to return."),
}, []string{"name"}),
},
{
@@ -367,8 +384,10 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "whoami",
Description: "Return the caller's identity, role, parent, project metadata, and available tools.",
InputSchema: objectSchema(nil, nil),
Description: "Return caller identity, role, parent, and project metadata.",
InputSchema: objectSchema(map[string]any{
"include_tools": booleanProp("Include full available tool list."),
}, nil),
},
{
Name: "help",
@@ -378,6 +397,16 @@ func toolCatalog() []toolDescriptor {
}, nil),
},
}
if role != RoleSubAgent {
return tools
}
filtered := tools[:0]
for _, tool := range tools {
if tool.Name != "spawn_agent" {
filtered = append(filtered, tool)
}
}
return filtered
}
// handleProtocolMethod handles MCP protocol-level methods. Returns
@@ -416,7 +445,14 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
return map[string]any{}, true, 0, "", nil
case "tools/list":
return map[string]any{"tools": toolCatalog()}, true, 0, "", nil
role := RoleOrchestrator
s.mu.Lock()
host := s.host
s.mu.Unlock()
if host != nil {
role = host.CallerRole(callerID)
}
return map[string]any{"tools": toolCatalog(role)}, true, 0, "", nil
case "tools/call":
var p struct {
@@ -472,25 +508,12 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
return nil, false, 0, "", nil
}
// wrapToolResult turns a structured tool result into an MCP tools/call
// response. Plain strings (e.g. "ok") become text content; structured
// values are JSON-encoded into a single text block and also exposed
// under structuredContent so capable clients can read the shape.
// wrapToolResult turns a tool result into an MCP tools/call response.
// Structured values are exposed once under structuredContent; content
// carries only a short model-readable summary to avoid duplicating
// large JSON payloads into the transcript.
func wrapToolResult(result any) map[string]any {
var text string
switch v := result.(type) {
case nil:
text = "ok"
case string:
text = v
default:
b, err := json.Marshal(v)
if err != nil {
text = fmt.Sprintf("%v", v)
} else {
text = string(b)
}
}
text := summarizeToolResult(result)
out := map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
"isError": false,
@@ -505,3 +528,70 @@ func wrapToolResult(result any) map[string]any {
}
return out
}
func summarizeToolResult(result any) string {
switch v := result.(type) {
case nil:
return "ok"
case string:
return v
case ProcessInfo:
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
case []ProcessInfo:
return fmt.Sprintf("%d processes", len(v))
case ProcessStatus:
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
case ProjectStatus:
return fmt.Sprintf("%d processes, %d scratchpads", len(v.Processes), len(v.Scratchpads))
case ProcessOutput:
return outputSummary(v.Mode, v.ContentBytes, v.Truncated, v.NewOffset)
case RawOutput:
return outputSummary("raw", v.ContentBytes, v.Truncated, v.NewOffset)
case SearchResult:
if v.Truncated {
return fmt.Sprintf("%d matches (truncated)", len(v.Matches))
}
return fmt.Sprintf("%d matches", len(v.Matches))
case SendInputResult:
if v.Tail != nil {
return "ok; tail included"
}
return "ok"
case TimerHandle:
return "timer " + v.ID
case TimerFireWhenIdleResponse:
if v.ID != "" {
return fmt.Sprintf("%s timer %s", v.Status, v.ID)
}
return v.Status
case []TimerInfo:
return fmt.Sprintf("%d timers", len(v))
case []scratchpad.Entry:
return fmt.Sprintf("%d scratchpads", len(v))
case ScratchpadReadResult:
if v.Truncated {
return fmt.Sprintf("%d/%d bytes from offset %d", v.ContentBytes, v.TotalBytes, v.Offset)
}
return fmt.Sprintf("%d bytes", v.ContentBytes)
case WhoAmI:
if v.ProcessID == "" {
return string(v.Role)
}
return fmt.Sprintf("%s %s", v.ProcessID, v.Role)
case HelpResponse:
return fmt.Sprintf("help: %s", v.Topic)
default:
return "ok"
}
}
func outputSummary(mode string, bytes int, truncated bool, offset int64) string {
s := fmt.Sprintf("%s output: %d bytes", mode, bytes)
if offset > 0 {
s += fmt.Sprintf(", offset %d", offset)
}
if truncated {
s += " (truncated)"
}
return s
}

View File

@@ -2,6 +2,7 @@ package mcp
import (
"encoding/json"
"strings"
"testing"
)
@@ -43,6 +44,9 @@ func TestInitializeReturnsCapabilities(t *testing.T) {
if !ok || instructions == "" {
t.Fatalf("instructions missing or wrong type: %+v", parsed.Result)
}
if len(instructions) > 320 {
t.Fatalf("instructions too verbose: %d chars", len(instructions))
}
}
func TestInitializedNotificationSuppressesResponse(t *testing.T) {
@@ -74,6 +78,9 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
if parsed.Error != nil {
t.Fatalf("tools/list returned error: %+v", parsed.Error)
}
if len(resp) > 12000 {
t.Fatalf("tools/list response too large: %d bytes", len(resp))
}
tools, ok := parsed.Result["tools"].([]interface{})
if !ok {
t.Fatalf("tools not array: %+v", parsed.Result)
@@ -112,6 +119,27 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
}
}
func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) {
result := ProcessOutput{
Content: strings.Repeat("x", 1024),
Mode: "stream",
NewOffset: 2048,
ContentBytes: 1024,
}
wrapped := wrapToolResult(result)
if wrapped["structuredContent"] == nil {
t.Fatalf("structuredContent missing: %#v", wrapped)
}
content := wrapped["content"].([]map[string]any)
text := content[0]["text"].(string)
if strings.Contains(text, result.Content) {
t.Fatalf("content duplicated structured payload: %q", text)
}
if !strings.Contains(text, "stream output") {
t.Fatalf("summary text should identify output, got %q", text)
}
}
func TestPingReturnsEmptyObject(t *testing.T) {
s := &Server{}
req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`)

View File

@@ -74,10 +74,10 @@ type ToolHost interface {
// Inspection.
ListProcesses(callerID, kindFilter string) []ProcessInfo
GetProcessStatus(callerID, processID string) (ProcessStatus, error)
GetProjectStatus(callerID string) (ProjectStatus, error)
GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (ProcessOutput, error)
GetProcessRawOutput(callerID, processID string, sinceOffset int64) (RawOutput, error)
SearchOutput(callerID, processID, pattern, kind string, limit int) (SearchResult, error)
GetProjectStatus(callerID string, includeTools bool) (ProjectStatus, error)
GetProcessOutput(callerID string, args ProcessOutputArgs) (ProcessOutput, error)
GetProcessRawOutput(callerID string, args RawOutputArgs) (RawOutput, error)
SearchOutput(callerID string, args SearchOutputArgs) (SearchResult, error)
WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error)
GetProcessPorts(callerID, processID string) ([]PortSighting, error)
@@ -97,14 +97,14 @@ type ToolHost interface {
TimerList(callerID string) ([]TimerInfo, error)
// Scratchpads.
ScratchpadList(callerID string) ([]scratchpad.Entry, error)
ScratchpadRead(callerID, name string) (content string, revision string, err error)
ScratchpadWrite(callerID, name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(callerID, name, content string) error
ScratchpadDelete(callerID, name string) error
ScratchpadList() ([]scratchpad.Entry, error)
ScratchpadRead(args ScratchpadReadArgs) (ScratchpadReadResult, error)
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error
ScratchpadDelete(name string) error
// Meta.
WhoAmI(callerID string) WhoAmI
WhoAmI(callerID string, includeTools bool) WhoAmI
Help(callerID, topic string) HelpResponse
}
@@ -157,32 +157,60 @@ type ProjectStatus struct {
Scratchpads []scratchpad.Entry `json:"scratchpads"`
}
type ProjectStatusArgs struct {
IncludeTools bool `json:"include_tools"`
}
// ProjectMeta is the project root info echoed in many payloads.
type ProjectMeta struct {
Path string `json:"path"`
Key string `json:"key"`
}
// ProcessOutput is the get_process_output payload. SPEC §7 enriches
// the old read_output result with screen geometry + version.
// ProcessOutput is the get_process_output payload. By default it is
// canonical text with light metadata; include_meta restores screen
// geometry + version, and raw requests return stream bytes.
type ProcessOutput struct {
Content string `json:"content"`
Mode string `json:"mode"`
NewOffset int64 `json:"new_offset,omitempty"`
ActiveScreen string `json:"active_screen,omitempty"`
Rows int `json:"rows,omitempty"`
Cols int `json:"cols,omitempty"`
Cursor Cursor `json:"cursor"`
IdleMS int64 `json:"idle_ms,omitempty"`
Status string `json:"status,omitempty"`
ScreenVersion int64 `json:"screen_version,omitempty"`
Content string `json:"content"`
Mode string `json:"mode"`
NewOffset int64 `json:"new_offset,omitempty"`
ActiveScreen string `json:"active_screen,omitempty"`
Rows int `json:"rows,omitempty"`
Cols int `json:"cols,omitempty"`
Cursor *Cursor `json:"cursor,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"`
Status string `json:"status,omitempty"`
ScreenVersion int64 `json:"screen_version,omitempty"`
ContentBytes int `json:"content_bytes,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TruncatedBytes int `json:"truncated_bytes,omitempty"`
Canonicalized bool `json:"canonicalized,omitempty"`
}
type ProcessOutputArgs struct {
ProcessID string `json:"process_id"`
Mode string `json:"mode"`
SinceOffset int64 `json:"since_offset"`
MaxBytes int `json:"max_bytes"`
MaxLines int `json:"max_lines"`
Raw bool `json:"raw"`
IncludeMeta bool `json:"include_meta"`
}
// RawOutput is the get_process_raw_output payload — ANSI preserved.
type RawOutput struct {
Content string `json:"content"`
NewOffset int64 `json:"new_offset"`
Status string `json:"status,omitempty"`
Content string `json:"content"`
NewOffset int64 `json:"new_offset"`
Status string `json:"status,omitempty"`
ContentBytes int `json:"content_bytes,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TruncatedBytes int `json:"truncated_bytes,omitempty"`
}
type RawOutputArgs struct {
ProcessID string `json:"process_id"`
SinceOffset int64 `json:"since_offset"`
MaxBytes int `json:"max_bytes"`
}
// SearchResult is search_output's payload.
@@ -191,6 +219,14 @@ type SearchResult struct {
Truncated bool `json:"truncated"`
}
type SearchOutputArgs struct {
ProcessID string `json:"process_id"`
Pattern string `json:"pattern"`
Kind string `json:"kind"`
Limit int `json:"limit"`
MaxBytes int `json:"max_bytes"`
}
type SearchMatch struct {
LineNo int `json:"line_no"`
Text string `json:"text"`
@@ -245,6 +281,7 @@ type TimerInfo struct {
ID string `json:"timer_id"`
Label string `json:"label,omitempty"`
Body string `json:"body,omitempty"`
BodyTruncated bool `json:"body_truncated,omitempty"`
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused"
OwnerID string `json:"owner_process_id"`
@@ -281,13 +318,14 @@ type SpawnProcessArgs struct {
// SendInputArgs is the input shape for send_input — covers text /
// paste / key with the optional wait+tail tail-after-send.
type SendInputArgs struct {
ProcessID string `json:"process_id"`
Kind string `json:"kind"` // "text" | "paste" | "key"
Text string `json:"text"`
Key string `json:"key"`
Submit *bool `json:"submit"`
WaitMS int `json:"wait_ms"`
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
ProcessID string `json:"process_id"`
Kind string `json:"kind"` // "text" | "paste" | "key"
Text string `json:"text"`
Key string `json:"key"`
Submit *bool `json:"submit"`
WaitMS int `json:"wait_ms"`
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
TailMaxBytes int `json:"tail_max_bytes"`
}
// SendInputResult is the return shape of send_input.
@@ -306,6 +344,27 @@ type WhoAmI struct {
AvailableTools []string `json:"available_tools"`
}
type WhoAmIArgs struct {
IncludeTools bool `json:"include_tools"`
}
type ScratchpadReadArgs struct {
Name string `json:"name"`
Offset int `json:"offset"`
MaxBytes int `json:"max_bytes"`
}
type ScratchpadReadResult struct {
Content string `json:"content"`
Revision string `json:"revision"`
Offset int `json:"offset,omitempty"`
NextOffset int `json:"next_offset,omitempty"`
ContentBytes int `json:"content_bytes,omitempty"`
TotalBytes int `json:"total_bytes,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TruncatedBytes int `json:"truncated_bytes,omitempty"`
}
// HelpResponse is the help return shape.
type HelpResponse struct {
Topic string `json:"topic"`
@@ -507,61 +566,51 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
return st, 0, "", nil
case "get_project_status":
ps, err := h.GetProjectStatus(callerID)
var p ProjectStatusArgs
_ = unmarshalParamsOptional(params, &p)
ps, err := h.GetProjectStatus(callerID, p.IncludeTools)
if err != nil {
return mapToolError(err)
}
return ps, 0, "", nil
case "get_process_output":
var p struct {
ProcessID string `json:"process_id"`
Mode string `json:"mode"`
SinceOffset int64 `json:"since_offset"`
}
var p ProcessOutputArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if p.Mode == "" {
p.Mode = "grid"
}
out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset)
out, err := h.GetProcessOutput(callerID, p)
if err != nil {
return mapToolError(err)
}
return out, 0, "", nil
case "get_process_raw_output":
var p struct {
ProcessID string `json:"process_id"`
SinceOffset int64 `json:"since_offset"`
}
var p RawOutputArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
out, err := h.GetProcessRawOutput(callerID, p.ProcessID, p.SinceOffset)
out, err := h.GetProcessRawOutput(callerID, p)
if err != nil {
return mapToolError(err)
}
return out, 0, "", nil
case "search_output":
var p struct {
ProcessID string `json:"process_id"`
Pattern string `json:"pattern"`
Kind string `json:"kind"`
Limit int `json:"limit"`
}
var p SearchOutputArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if p.Limit <= 0 {
p.Limit = 20
p.Limit = 10
}
if p.Kind == "" {
p.Kind = "rendered"
}
res, err := h.SearchOutput(callerID, p.ProcessID, p.Pattern, p.Kind, p.Limit)
res, err := h.SearchOutput(callerID, p)
if err != nil {
return mapToolError(err)
}
@@ -724,24 +773,22 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
return ts, 0, "", nil
case "scratchpad_list":
entries, err := h.ScratchpadList(callerID)
entries, err := h.ScratchpadList()
if err != nil {
return nil, codeInternal, err.Error(), nil
}
return entries, 0, "", nil
case "scratchpad_read":
var p struct {
Name string `json:"name"`
}
var p ScratchpadReadArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
content, rev, err := h.ScratchpadRead(callerID, p.Name)
res, err := h.ScratchpadRead(p)
if err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"content": content, "revision": rev}, 0, "", nil
return res, 0, "", nil
case "scratchpad_write":
var p struct {
@@ -752,7 +799,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
rev, err := h.ScratchpadWrite(callerID, p.Name, p.Content, p.ExpectedRevision)
rev, err := h.ScratchpadWrite(p.Name, p.Content, p.ExpectedRevision)
if err != nil {
// Optimistic-concurrency miss returns ok:false + current_revision
// rather than a JSON-RPC error so callers can re-read + merge.
@@ -772,7 +819,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ScratchpadAppend(callerID, p.Name, p.Content); err != nil {
if err := h.ScratchpadAppend(p.Name, p.Content); err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"ok": true}, 0, "", nil
@@ -784,13 +831,15 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ScratchpadDelete(callerID, p.Name); err != nil {
if err := h.ScratchpadDelete(p.Name); err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"ok": true}, 0, "", nil
case "whoami":
return h.WhoAmI(callerID), 0, "", nil
var p WhoAmIArgs
_ = unmarshalParamsOptional(params, &p)
return h.WhoAmI(callerID, p.IncludeTools), 0, "", nil
case "help":
var p struct {

View File

@@ -1,176 +0,0 @@
// Package protocol defines the daemon/client control frames shared by
// transports. It intentionally contains data shapes only; app behavior stays
// in internal/app until the headless daemon split is complete.
package protocol
import (
"encoding/json"
"fmt"
"time"
)
// FrameType identifies one protocol message kind.
type FrameType string
const (
FrameHello FrameType = "hello"
FrameAuthChallenge FrameType = "auth_challenge"
FrameAuthOK FrameType = "auth_ok"
FrameAttach FrameType = "attach"
FrameDetach FrameType = "detach"
FrameProjectList FrameType = "project_list"
FrameChrome FrameType = "chrome"
FramePaneSnapshot FrameType = "pane_snapshot"
FramePaneChunk FrameType = "pane_chunk"
FrameLifecycle FrameType = "lifecycle"
FrameAttention FrameType = "attention"
FrameTrustPrompt FrameType = "trust_prompt"
FrameInput FrameType = "input"
FrameFocus FrameType = "focus"
FrameSwitchProject FrameType = "switch_project"
FrameOpenProject FrameType = "open_project"
FramePaletteCommand FrameType = "palette_command"
FrameTrustResponse FrameType = "trust_response"
FrameResize FrameType = "resize"
FrameList FrameType = "list"
FrameStop FrameType = "stop"
FrameError FrameType = "error"
)
// Frame is the transport envelope. Payload is deliberately raw JSON so
// network transports can frame without knowing every message type; loopback
// transports may pass the same bytes without JSON re-encoding.
type Frame struct {
Type FrameType `json:"type"`
RequestID string `json:"request_id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewFrame marshals payload into a protocol frame.
func NewFrame[T any](typ FrameType, payload T) (Frame, error) {
b, err := json.Marshal(payload)
if err != nil {
return Frame{}, fmt.Errorf("protocol: marshal %s: %w", typ, err)
}
return Frame{Type: typ, Payload: b}, nil
}
// Decode unmarshals f.Payload into v.
func Decode[T any](f Frame) (T, error) {
var v T
if len(f.Payload) == 0 {
return v, nil
}
if err := json.Unmarshal(f.Payload, &v); err != nil {
return v, fmt.Errorf("protocol: decode %s: %w", f.Type, err)
}
return v, nil
}
type Hello struct {
Version int `json:"version"`
DaemonID string `json:"daemon_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
}
type Attach struct {
Token string `json:"token,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
ProjectPath string `json:"project_path,omitempty"`
TermSize Size `json:"term_size"`
}
type Detach struct {
ClientID string `json:"client_id,omitempty"`
}
type Size struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
type Project struct {
Key string `json:"key"`
Path string `json:"path"`
Name string `json:"name"`
LastActive time.Time `json:"last_active,omitempty"`
TabCount int `json:"tab_count"`
}
type ProjectList struct {
Projects []Project `json:"projects"`
}
type Chrome struct {
ProjectKey string `json:"project_key"`
Model json.RawMessage `json:"model"`
}
type PaneSnapshot struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
Size Size `json:"size,omitempty"`
DisplayOwner bool `json:"display_owner,omitempty"`
}
type PaneChunk struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
Size Size `json:"size,omitempty"`
DisplayOwner bool `json:"display_owner,omitempty"`
}
type LifecycleKind string
const (
LifecycleSpawned LifecycleKind = "spawned"
LifecycleExited LifecycleKind = "exited"
LifecycleClosed LifecycleKind = "closed"
LifecycleStateChanged LifecycleKind = "state_changed"
)
type Lifecycle struct {
Kind LifecycleKind `json:"kind"`
ProjectKey string `json:"project_key,omitempty"`
ChildID string `json:"child_id,omitempty"`
Child json.RawMessage `json:"child,omitempty"`
State string `json:"state,omitempty"`
}
type Input struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type Focus struct {
PaneID string `json:"pane_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type SwitchProject struct {
Key string `json:"key"`
}
type OpenProject struct {
Path string `json:"path"`
}
type PaletteCommand struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"data,omitempty"`
}
type TrustResponse struct {
ProcessID string `json:"process_id"`
Preset string `json:"preset"`
Allow bool `json:"allow"`
}
type Resize struct {
Size Size `json:"size"`
}
type Error struct {
Message string `json:"message"`
}

View File

@@ -1,67 +0,0 @@
package protocol
import (
"sync"
)
const defaultLoopbackBuffer = 64
// NewLoopbackPair returns connected in-process transports. Frames cross the
// same Send/Recv boundary as network transports, but payload bytes are passed
// directly without JSON re-encoding.
func NewLoopbackPair() (client Transport, daemon Transport) {
c2d := make(chan Frame, defaultLoopbackBuffer)
d2c := make(chan Frame, defaultLoopbackBuffer)
return &loopbackTransport{send: c2d, recv: d2c}, &loopbackTransport{send: d2c, recv: c2d}
}
type loopbackTransport struct {
send chan<- Frame
recv <-chan Frame
once sync.Once
done chan struct{}
}
func (t *loopbackTransport) init() {
if t.done == nil {
t.done = make(chan struct{})
}
}
func (t *loopbackTransport) Send(f Frame) error {
t.init()
select {
case <-t.done:
return ErrTransportClosed
case t.send <- cloneFrame(f):
return nil
}
}
func (t *loopbackTransport) Recv() (Frame, error) {
t.init()
select {
case <-t.done:
return Frame{}, ErrTransportClosed
case f, ok := <-t.recv:
if !ok {
return Frame{}, ErrTransportClosed
}
return f, nil
}
}
func (t *loopbackTransport) Close() error {
t.init()
t.once.Do(func() {
close(t.done)
})
return nil
}
func cloneFrame(f Frame) Frame {
if len(f.Payload) > 0 {
f.Payload = append([]byte(nil), f.Payload...)
}
return f
}

View File

@@ -1,51 +0,0 @@
package protocol
import "testing"
func TestLoopbackUsesFramePayload(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
sent, err := NewFrame(FrameInput, Input{PaneID: "p_123456", Bytes: []byte("hello")})
if err != nil {
t.Fatalf("NewFrame: %v", err)
}
if err := client.Send(sent); err != nil {
t.Fatalf("Send: %v", err)
}
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Type != FrameInput {
t.Fatalf("type = %q, want %q", got.Type, FrameInput)
}
payload, err := Decode[Input](got)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if payload.PaneID != "p_123456" || string(payload.Bytes) != "hello" {
t.Fatalf("payload = %#v", payload)
}
}
func TestLoopbackCopiesPayloadOnSend(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
f := Frame{Type: FramePaneChunk, Payload: []byte(`{"pane_id":"p","bytes":"aGVsbG8="}`)}
if err := client.Send(f); err != nil {
t.Fatalf("Send: %v", err)
}
f.Payload[0] = 'x'
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Payload[0] != '{' {
t.Fatalf("payload was retained instead of copied: %q", string(got.Payload))
}
}

View File

@@ -1,80 +0,0 @@
package protocol
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"sync"
)
var ErrTransportClosed = errors.New("protocol: transport closed")
// Transport carries framed daemon/client protocol messages.
type Transport interface {
Send(Frame) error
Recv() (Frame, error)
Close() error
}
// ConnTransport is a JSON-lines implementation over a stream connection. Send
// is guarded by a mutex so the daemon can push frames from its subscriber pump
// and its command handlers concurrently; Close may be called from any goroutine
// (e.g. on context cancellation) to unblock a pending Recv.
type ConnTransport struct {
conn net.Conn
r *bufio.Reader
wmu sync.Mutex
w *bufio.Writer
}
func NewConnTransport(conn net.Conn) *ConnTransport {
return &ConnTransport{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
}
func (t *ConnTransport) Send(f Frame) error {
if t == nil || t.conn == nil {
return ErrTransportClosed
}
b, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("protocol: encode frame: %w", err)
}
t.wmu.Lock()
defer t.wmu.Unlock()
if _, err := t.w.Write(append(b, '\n')); err != nil {
return err
}
return t.w.Flush()
}
func (t *ConnTransport) Recv() (Frame, error) {
if t == nil || t.conn == nil {
return Frame{}, ErrTransportClosed
}
line, err := t.r.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return Frame{}, ErrTransportClosed
}
return Frame{}, err
}
var f Frame
if err := json.Unmarshal(line, &f); err != nil {
return Frame{}, fmt.Errorf("protocol: decode frame: %w", err)
}
return f, nil
}
func (t *ConnTransport) Close() error {
if t == nil || t.conn == nil {
return nil
}
return t.conn.Close()
}

View File

@@ -6,22 +6,12 @@ import (
"io"
"os"
"os/exec"
"sync"
"syscall"
cpty "github.com/creack/pty"
)
// PTY holds a child process attached to a pseudo-terminal master fd.
//
// mu guards the master field only. Read/Write/Resize capture the *os.File
// under the lock and then do the (potentially blocking) I/O without holding
// it, so Close can swap master to nil and close the fd concurrently — closing
// the captured *os.File unblocks an in-flight Read. This avoids a data race
// between pumpChild's Read and Session.Shutdown's Close, which the daemon now
// hits routinely (daemon stop, not just process exit).
type PTY struct {
mu sync.Mutex
master *os.File
cmd *exec.Cmd
}
@@ -29,13 +19,11 @@ type PTY struct {
// 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, workDir string, cols, rows uint16) (*PTY, error) {
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:]...)
cmd.Dir = workDir
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
if env != nil {
cmd.Env = ensureTerm(env)
} else {
@@ -54,33 +42,24 @@ func Start(argv []string, env []string, workDir string, cols, rows uint16) (*PTY
}
func (p *PTY) Read(b []byte) (int, error) {
p.mu.Lock()
m := p.master
p.mu.Unlock()
if m == nil {
if p.master == nil {
return 0, io.ErrClosedPipe
}
return m.Read(b)
return p.master.Read(b)
}
func (p *PTY) Write(b []byte) (int, error) {
p.mu.Lock()
m := p.master
p.mu.Unlock()
if m == nil {
if p.master == nil {
return 0, io.ErrClosedPipe
}
return m.Write(b)
return p.master.Write(b)
}
func (p *PTY) Resize(cols, rows uint16) error {
p.mu.Lock()
m := p.master
p.mu.Unlock()
if m == nil {
if p.master == nil {
return io.ErrClosedPipe
}
return cpty.Setsize(m, &cpty.Winsize{Cols: cols, Rows: rows})
return cpty.Setsize(p.master, &cpty.Winsize{Cols: cols, Rows: rows})
}
// Wait blocks until the child exits and returns its exit error if any.
@@ -101,21 +80,14 @@ func (p *PTY) Pid() int {
// Close terminates the child (best effort) and releases the master fd.
func (p *PTY) Close() error {
p.mu.Lock()
m := p.master
p.master = nil
p.mu.Unlock()
var firstErr error
if m != nil {
if err := m.Close(); err != nil {
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 {
pid := p.cmd.Process.Pid
if pid > 0 {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
_ = p.cmd.Process.Kill()
}
return firstErr

View File

@@ -1,84 +0,0 @@
package pty
import (
"bytes"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestStartUsesWorkDir(t *testing.T) {
dir := t.TempDir()
p, err := Start([]string{"sh", "-c", "pwd"}, nil, dir, 80, 24)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
var out bytes.Buffer
buf := make([]byte, 256)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := p.Read(buf)
if n > 0 {
out.Write(buf[:n])
if strings.Contains(out.String(), dir) {
break
}
}
if err != nil {
break
}
}
_ = p.Wait()
if got := strings.TrimSpace(out.String()); got != dir {
t.Fatalf("pwd output = %q, want %q", got, dir)
}
}
func TestCloseKillsProcessGroup(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "sleep.pid")
env := append(os.Environ(), "PIDFILE="+pidFile)
p, err := Start([]string{"sh", "-c", "sleep 30 & echo $! > \"$PIDFILE\"; wait"}, env, "", 80, 24)
if err != nil {
t.Fatalf("Start: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
var childPID int
for time.Now().Before(deadline) {
b, err := os.ReadFile(pidFile)
if err == nil {
childPID, _ = strconv.Atoi(strings.TrimSpace(string(b)))
if childPID > 0 {
break
}
}
time.Sleep(20 * time.Millisecond)
}
if childPID <= 0 {
_ = p.Close()
t.Fatalf("background child pid was not written")
}
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
_ = p.Wait()
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
err := syscall.Kill(childPID, 0)
if errors.Is(err, syscall.ESRCH) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("background child pid %d still exists after PTY.Close", childPID)
}