diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc40a4..7967163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ 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. @@ -18,6 +47,8 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). over MCP. ### 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. diff --git a/cmd/patterm/main.go b/cmd/patterm/main.go index 3cd2dce..7709ad3 100644 --- a/cmd/patterm/main.go +++ b/cmd/patterm/main.go @@ -14,7 +14,9 @@ package main import ( "context" + "encoding/json" "fmt" + "net" "os" "path/filepath" "runtime" @@ -27,6 +29,7 @@ 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=..."`. @@ -48,10 +51,25 @@ 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)") ) @@ -72,6 +90,8 @@ func main() { } if *projectDir != "" { cwd = *projectDir + } else if flag.NArg() > 0 { + cwd = flag.Arg(0) } key, err := projectkey.Key(cwd) if err != nil { @@ -95,11 +115,26 @@ func main() { defer stopProfile() ctx := context.Background() - if err := app.Run(ctx, app.Options{ + 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{ ProjectDir: cwd, - ProjectKey: key, - DebugDir: resolvedDebug, - ProfileDir: resolvedProfile, + Stdin: os.Stdin, + Stdout: os.Stdout, + RawMode: true, + AutoStart: true, }); err != nil { die("%v", err) } @@ -194,6 +229,141 @@ 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 { diff --git a/cmd/spike/main.go b/cmd/spike/main.go index a3999c5..1a01ea3 100644 --- a/cmd/spike/main.go +++ b/cmd/spike/main.go @@ -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) } diff --git a/docs/daemon-client-plan.md b/docs/daemon-client-plan.md new file mode 100644 index 0000000..ff81690 --- /dev/null +++ b/docs/daemon-client-plan.md @@ -0,0 +1,273 @@ +# patterm: persistent daemon + thin networked client — implementation plan + +Status: implemented — Phases 0–4 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/.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? diff --git a/internal/app/app.go b/internal/app/app.go index a4a9339..052bfe8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "os/signal" + "path/filepath" "strings" "sync" "sync/atomic" @@ -18,7 +19,6 @@ 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,27 +60,6 @@ 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. @@ -90,48 +69,10 @@ func Run(ctx context.Context, opts Options) error { } defer mcpSrv.Close() - sess := NewSession(opts.ProjectDir, opts.ProjectKey) - defer sess.Shutdown() - - // Debug capture: when --debug= is set, write a verbose log - // (patterm.log), per-child raw PTY output (.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())) @@ -156,28 +97,51 @@ func Run(ctx context.Context, opts Options) error { defer metrics.close() } - // 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) + 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) + } st := &uiState{ - sess: sess, - presets: presets, - launcher: launcher, - pads: pads, - chromeWake: make(chan struct{}, 1), - trust: trustStore, - timers: host.timers, - hostCols: cols, - hostRows: rows, + 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, + }, stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), metrics: metrics, settings: appSettings, settingsPath: settingsPath, ctx: ctx, } - st.summaries = newSummaryManager(sess, opts.ProjectDir, presets, func() autoSummarySettings { + st.summaries = newSummaryManager(project.Session, project.Dir, presets, func() autoSummarySettings { st.settingsMu.Lock() defer st.settingsMu.Unlock() return st.settings.AutoSummary.clone() @@ -189,13 +153,10 @@ func Run(ctx context.Context, opts Options) error { st.flashError(fmt.Sprintf("summary: %v", result.Error)) } }) - sess.SetMetrics(metrics) - host.attention = st - host.focus = st - host.prompter = st - host.scratch = st + project.Session.SetMetrics(metrics) + st.attachProjectSinks(project) st.lastExit.Store(-1) - sess.Subscribe(st) + project.Session.Subscribe(st) go st.summaries.run(ctx) st.enterScreen() @@ -206,15 +167,13 @@ 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. - sess.ResizeAll(layout.childCols(), layout.childRows()) - launcher.SetSize(layout.childCols(), layout.childRows()) - host.SetSize(layout.childCols(), layout.childRows()) + registry.ResizeAll(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 savedProcesses { - c, err := launcher.RestoreCommand(e, presets) + for _, e := range project.savedProcess { + c, err := project.Launcher.RestoreCommand(e, presets) if err != nil { st.dbgf("restore process %s (%s): %v", e.Name, e.ID, err) continue @@ -252,6 +211,7 @@ 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() @@ -259,9 +219,7 @@ func Run(ctx context.Context, opts Options) error { st.renderer.SetLayout(l) } st.mu.Unlock() - sess.ResizeAll(l.childCols(), l.childRows()) - launcher.SetSize(l.childCols(), l.childRows()) - host.SetSize(l.childCols(), l.childRows()) + registry.ResizeAll(l.childCols(), l.childRows()) st.clearScreen() st.drawTabBar() st.drawSidebar() @@ -398,6 +356,8 @@ 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 @@ -408,6 +368,7 @@ type uiState struct { outMu sync.Mutex mu sync.Mutex + view ClientView palette *paletteState focusedID string focusedName string @@ -509,6 +470,97 @@ 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...) } @@ -574,6 +626,21 @@ 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) { @@ -586,9 +653,7 @@ func (st *uiState) focusProcess(processID string) { onAlt := childIsOnAlt(c) st.mu.Lock() leavingPad := st.focusedPad != "" - st.focusedPad = "" - st.focusedID = c.ID - st.focusedName = c.DisplayName() + st.focusChildLocked(c) st.updateActiveAgentLocked(c) r := newViewportRenderer(layout) r.SetChildOnAlt(onAlt) @@ -651,12 +716,7 @@ func (st *uiState) focusScratchpad(name string) { } st.marquee.reset() st.mu.Lock() - if st.padOffsetName != name { - st.padOffset = 0 - st.padOffsetName = name - } - st.focusedPad = name - st.focusedID = "" + st.focusPadLocked(name) st.focusedName = name st.renderer = nil st.mu.Unlock() @@ -711,8 +771,7 @@ func (st *uiState) restartFocusedCommand(processID string) { layout := st.layoutSnapshot() renderer := newViewportRenderer(layout) st.mu.Lock() - st.focusedID = c.ID - st.focusedName = c.DisplayName() + st.focusChildLocked(c) st.renderer = renderer st.repaintNextPTY = c.ID st.repaintNextPTYBudget = 2 @@ -747,6 +806,7 @@ 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. @@ -760,6 +820,7 @@ func (st *uiState) updateActiveAgentLocked(c *Child) { } if root.Kind == KindAgent && root.ParentID == "" { st.activeAgentID = root.ID + st.view.ActiveAgentID = root.ID } } @@ -822,9 +883,7 @@ func (st *uiState) OnChildSpawned(c *Child) { layout := st.layoutSnapshot() onAlt := childIsOnAlt(c) st.mu.Lock() - st.focusedPad = "" - st.focusedID = c.ID - st.focusedName = c.DisplayName() + st.focusChildLocked(c) st.updateActiveAgentLocked(c) renderer := newViewportRenderer(layout) renderer.SetChildOnAlt(onAlt) @@ -899,10 +958,10 @@ func (st *uiState) OnChildExited(c *Child) { if next == nil { st.focusedID = "" st.focusedName = "" + st.view.FocusedID = "" renderEmpty = true } else { - st.focusedID = next.ID - st.focusedName = next.DisplayName() + st.focusChildLocked(next) st.updateActiveAgentLocked(next) st.renderer = newViewportRenderer(layout) } @@ -911,6 +970,7 @@ 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() @@ -1269,6 +1329,10 @@ 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) @@ -1297,9 +1361,13 @@ func (st *uiState) drawStatusLine() { owner = "you have control" } } - left := "" + left := projectName if focusName != "" { - left = focusName + if left != "" { + left = left + " · " + focusName + } else { + left = focusName + } } if owner != "" { if left != "" { @@ -1387,7 +1455,10 @@ func (st *uiState) renderEmptyState() { func (st *uiState) hostSizeSnapshot() (uint16, uint16) { st.dimsMu.Lock() defer st.dimsMu.Unlock() - return st.hostCols, st.hostRows + if st.view.Cols == 0 || st.view.Rows == 0 { + return st.hostCols, st.hostRows + } + return st.view.Cols, st.view.Rows } func (st *uiState) layoutSnapshot() terminalLayout { @@ -1397,7 +1468,10 @@ func (st *uiState) layoutSnapshot() terminalLayout { } func (st *uiState) layoutLocked() terminalLayout { - return newTerminalLayout(st.hostCols, st.hostRows) + if st.view.Cols == 0 || st.view.Rows == 0 { + return newTerminalLayout(st.hostCols, st.hostRows) + } + return newTerminalLayout(st.view.Cols, st.view.Rows) } // splitOnEnter walks input and returns each Enter byte (CR or LF) as @@ -1966,6 +2040,20 @@ 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 @@ -2086,9 +2174,7 @@ func (st *uiState) closePalette(action paletteAction) { layout := st.layoutSnapshot() st.mu.Lock() leavingPad := st.focusedPad != "" - st.focusedPad = "" - st.focusedID = action.childID - st.focusedName = c.DisplayName() + st.focusChildLocked(c) st.updateActiveAgentLocked(c) st.renderer = newViewportRenderer(layout) st.mu.Unlock() @@ -2103,6 +2189,42 @@ 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. @@ -2232,13 +2354,8 @@ func (st *uiState) handlePadDelete(name string) { if entries := st.padsList(); len(entries) > 0 { next := entries[0].Name st.mu.Lock() - st.focusedPad = next - st.focusedID = "" + st.focusPadLocked(next) st.focusedName = next - if st.padOffsetName != next { - st.padOffset = 0 - st.padOffsetName = next - } st.mu.Unlock() st.repaintFocusedWithChrome() return @@ -2249,9 +2366,12 @@ 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() @@ -2278,7 +2398,7 @@ func (st *uiState) handlePadRename(oldName, newName string) { } st.mu.Lock() if st.focusedPad == oldName { - st.focusedPad = newName + st.focusPadLocked(newName) } st.mu.Unlock() st.scratchpadsChanged() @@ -2549,6 +2669,7 @@ 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 @@ -2606,6 +2727,7 @@ func (st *uiState) exitPadView() { return } st.focusedPad = "" + st.view.FocusedPad = "" st.focusedName = "" st.mu.Unlock() st.clearViewportArea() @@ -2632,6 +2754,7 @@ func (st *uiState) padScroll(delta int) { if st.padOffset < 0 { st.padOffset = 0 } + st.view.PadOffset = st.padOffset st.mu.Unlock() st.repaintFocusedPad() } diff --git a/internal/app/child.go b/internal/app/child.go index 91f96f4..300c965 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -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, cols, rows) + p, err := pkgpty.Start(c.Argv, c.Env, c.WorkDir, cols, rows) if err != nil { em.Close() errored := StatusErrored diff --git a/internal/app/chrome_model.go b/internal/app/chrome_model.go new file mode 100644 index 0000000..cbbf74c --- /dev/null +++ b/internal/app/chrome_model.go @@ -0,0 +1,80 @@ +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()), + } +} diff --git a/internal/app/chrome_model_test.go b/internal/app/chrome_model_test.go new file mode 100644 index 0000000..b65f193 --- /dev/null +++ b/internal/app/chrome_model_test.go @@ -0,0 +1,24 @@ +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) + } +} diff --git a/internal/app/client_net.go b/internal/app/client_net.go new file mode 100644 index 0000000..ad36de4 --- /dev/null +++ b/internal/app/client_net.go @@ -0,0 +1,677 @@ +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 +} diff --git a/internal/app/client_net_test.go b/internal/app/client_net_test.go new file mode 100644 index 0000000..ef236a4 --- /dev/null +++ b/internal/app/client_net_test.go @@ -0,0 +1,157 @@ +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 +} diff --git a/internal/app/client_subscriber.go b/internal/app/client_subscriber.go new file mode 100644 index 0000000..b22b4e4 --- /dev/null +++ b/internal/app/client_subscriber.go @@ -0,0 +1,135 @@ +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 +} diff --git a/internal/app/client_subscriber_test.go b/internal/app/client_subscriber_test.go new file mode 100644 index 0000000..d9b325c --- /dev/null +++ b/internal/app/client_subscriber_test.go @@ -0,0 +1,32 @@ +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") + } +} diff --git a/internal/app/client_view.go b/internal/app/client_view.go new file mode 100644 index 0000000..ba7939f --- /dev/null +++ b/internal/app/client_view.go @@ -0,0 +1,40 @@ +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 +} diff --git a/internal/app/daemon_core.go b/internal/app/daemon_core.go new file mode 100644 index 0000000..6a8f350 --- /dev/null +++ b/internal/app/daemon_core.go @@ -0,0 +1,530 @@ +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) +} diff --git a/internal/app/daemon_net.go b/internal/app/daemon_net.go new file mode 100644 index 0000000..5619759 --- /dev/null +++ b/internal/app/daemon_net.go @@ -0,0 +1,481 @@ +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) +} diff --git a/internal/app/daemon_net_test.go b/internal/app/daemon_net_test.go new file mode 100644 index 0000000..98978c2 --- /dev/null +++ b/internal/app/daemon_net_test.go @@ -0,0 +1,477 @@ +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 +} diff --git a/internal/app/host.go b/internal/app/host.go index 4f5ad49..d220d68 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -811,13 +811,13 @@ func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) { // Scratchpads / Meta // ─────────────────────────────────────────────────────────────────── -func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() } +func (h *toolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return h.pads.List() } -func (h *toolHost) ScratchpadRead(name string) (string, string, error) { +func (h *toolHost) ScratchpadRead(_ string, name string) (string, string, error) { return h.pads.Read(name) } -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 +825,7 @@ func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (stri 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 +833,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() diff --git a/internal/app/palette.go b/internal/app/palette.go index 76bb8ce..f3888ac 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -40,6 +40,9 @@ 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 @@ -48,6 +51,7 @@ type paletteAction struct { // an equally tight Spawn-section hit. const ( groupFocused = iota + groupProject groupOpen groupSpawn groupSettings @@ -64,6 +68,14 @@ 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 @@ -120,10 +132,12 @@ type paletteState struct { items []paletteItem - mode paletteMode - form *spawnProcessForm - renameForm *renameForm - settingsInput *settingsInputForm + mode paletteMode + form *spawnProcessForm + renameForm *renameForm + settingsInput *settingsInputForm + projects []paletteProject + currentProject string // showHelp swaps the item list for a static keybinding cheat-sheet // until the next keystroke. Toggled by `?` in picker mode. @@ -189,6 +203,12 @@ 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). @@ -294,7 +314,33 @@ func (p *paletteState) buildItems(macro string) []paletteItem { } } - // Group 1: Open — switch entries for every running child *other than* + 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* // 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. @@ -655,6 +701,9 @@ 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 @@ -913,6 +962,9 @@ 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"} } diff --git a/internal/app/project_registry_test.go b/internal/app/project_registry_test.go new file mode 100644 index 0000000..3df2092 --- /dev/null +++ b/internal/app/project_registry_test.go @@ -0,0 +1,162 @@ +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) + } +} diff --git a/internal/app/scratchpad_delete_test.go b/internal/app/scratchpad_delete_test.go index 6074098..72fc547 100644 --- a/internal/app/scratchpad_delete_test.go +++ b/internal/app/scratchpad_delete_test.go @@ -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 { diff --git a/internal/app/session.go b/internal/app/session.go index b7851f3..155f1f6 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -46,6 +46,13 @@ 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). @@ -118,6 +125,16 @@ 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) { @@ -136,6 +153,24 @@ 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 { @@ -146,16 +181,30 @@ 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 @@ -165,18 +214,27 @@ 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 { @@ -226,6 +284,9 @@ 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() @@ -681,6 +742,22 @@ 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. diff --git a/internal/app/token.go b/internal/app/token.go new file mode 100644 index 0000000..e563be6 --- /dev/null +++ b/internal/app/token.go @@ -0,0 +1,63 @@ +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 +} diff --git a/internal/harness/restart_persist_test.go b/internal/harness/restart_persist_test.go index c278df5..a7460b8 100644 --- a/internal/harness/restart_persist_test.go +++ b/internal/harness/restart_persist_test.go @@ -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, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows) + p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) if err != nil { _ = em.Close() t.Fatalf("pty start: %v", err) diff --git a/internal/harness/session.go b/internal/harness/session.go index 7928353..bea0b52 100644 --- a/internal/harness/session.go +++ b/internal/harness/session.go @@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) { if err != nil { return nil, err } - p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows) + p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) if err != nil { _ = em.Close() return nil, err diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index c1df603..2193bb0 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -188,6 +188,9 @@ func RunStdioProxy(socket, identity string) error { // ""} + 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 { diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 066f080..a20139f 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -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() ([]scratchpad.Entry, error) { return nil, nil } -func (h *blockingToolHost) ScratchpadRead(string) (string, string, error) { +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) ScratchpadWrite(string, string, string) (string, error) { +func (h *blockingToolHost) ScratchpadWrite(string, string, string, string) (string, error) { return "", nil } -func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil } -func (h *blockingToolHost) ScratchpadDelete(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, 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{} } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index f005026..9a9f581 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -97,11 +97,11 @@ type ToolHost interface { TimerList(callerID string) ([]TimerInfo, error) // Scratchpads. - ScratchpadList() ([]scratchpad.Entry, error) - ScratchpadRead(name string) (content string, revision string, err error) - ScratchpadWrite(name, content, expectedRevision string) (revision string, err error) - ScratchpadAppend(name, content string) error - ScratchpadDelete(name string) error + 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 // Meta. WhoAmI(callerID string) WhoAmI @@ -724,7 +724,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, return ts, 0, "", nil case "scratchpad_list": - entries, err := h.ScratchpadList() + entries, err := h.ScratchpadList(callerID) if err != nil { return nil, codeInternal, err.Error(), nil } @@ -737,7 +737,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 } - content, rev, err := h.ScratchpadRead(p.Name) + content, rev, err := h.ScratchpadRead(callerID, p.Name) if err != nil { return nil, codeInternal, err.Error(), nil } @@ -752,7 +752,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(p.Name, p.Content, p.ExpectedRevision) + rev, err := h.ScratchpadWrite(callerID, 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 +772,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(p.Name, p.Content); err != nil { + if err := h.ScratchpadAppend(callerID, p.Name, p.Content); err != nil { return nil, codeInternal, err.Error(), nil } return map[string]any{"ok": true}, 0, "", nil @@ -784,7 +784,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.ScratchpadDelete(p.Name); err != nil { + if err := h.ScratchpadDelete(callerID, p.Name); err != nil { return nil, codeInternal, err.Error(), nil } return map[string]any{"ok": true}, 0, "", nil diff --git a/internal/protocol/frame.go b/internal/protocol/frame.go new file mode 100644 index 0000000..982297c --- /dev/null +++ b/internal/protocol/frame.go @@ -0,0 +1,176 @@ +// 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"` +} diff --git a/internal/protocol/loopback.go b/internal/protocol/loopback.go new file mode 100644 index 0000000..d8f61ae --- /dev/null +++ b/internal/protocol/loopback.go @@ -0,0 +1,67 @@ +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 +} diff --git a/internal/protocol/loopback_test.go b/internal/protocol/loopback_test.go new file mode 100644 index 0000000..ab001d9 --- /dev/null +++ b/internal/protocol/loopback_test.go @@ -0,0 +1,51 @@ +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)) + } +} diff --git a/internal/protocol/transport.go b/internal/protocol/transport.go new file mode 100644 index 0000000..45bf531 --- /dev/null +++ b/internal/protocol/transport.go @@ -0,0 +1,80 @@ +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() +} diff --git a/internal/pty/pty.go b/internal/pty/pty.go index 4f8beca..f18c696 100644 --- a/internal/pty/pty.go +++ b/internal/pty/pty.go @@ -6,12 +6,22 @@ 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 } @@ -19,11 +29,13 @@ 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, cols, rows uint16) (*PTY, error) { +func Start(argv []string, env []string, workDir 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 { @@ -42,24 +54,33 @@ func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) { } func (p *PTY) Read(b []byte) (int, error) { - if p.master == nil { + p.mu.Lock() + m := p.master + p.mu.Unlock() + if m == nil { return 0, io.ErrClosedPipe } - return p.master.Read(b) + return m.Read(b) } func (p *PTY) Write(b []byte) (int, error) { - if p.master == nil { + p.mu.Lock() + m := p.master + p.mu.Unlock() + if m == nil { return 0, io.ErrClosedPipe } - return p.master.Write(b) + return m.Write(b) } func (p *PTY) Resize(cols, rows uint16) error { - if p.master == nil { + p.mu.Lock() + m := p.master + p.mu.Unlock() + if m == nil { return io.ErrClosedPipe } - return cpty.Setsize(p.master, &cpty.Winsize{Cols: cols, Rows: rows}) + return cpty.Setsize(m, &cpty.Winsize{Cols: cols, Rows: rows}) } // Wait blocks until the child exits and returns its exit error if any. @@ -80,14 +101,21 @@ 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 p.master != nil { - if err := p.master.Close(); err != nil && firstErr == nil { + if m != nil { + if err := m.Close(); err != 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 diff --git a/internal/pty/pty_test.go b/internal/pty/pty_test.go new file mode 100644 index 0000000..1aa9d94 --- /dev/null +++ b/internal/pty/pty_test.go @@ -0,0 +1,84 @@ +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) +}