add local daemon socket protocol

This commit is contained in:
2026-05-27 13:55:38 +01:00
parent c56de27f44
commit d07a09d64f
6 changed files with 709 additions and 3 deletions

View File

@@ -7,6 +7,12 @@ 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.
- 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
@@ -25,6 +31,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.

View File

@@ -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,6 +51,15 @@ 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] == "ls" {
runDaemonList()
return
}
var (
projectDir = flag.String("project", "", "project directory (default $PWD)")
@@ -194,6 +206,78 @@ 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)")
flag.Parse()
cwd, err := os.Getwd()
if err != nil {
die("getwd: %v", err)
}
if *projectDir != "" {
cwd = *projectDir
}
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil {
die("daemon: %v", err)
}
}
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 {

375
internal/app/daemon_net.go Normal file
View File

@@ -0,0 +1,375 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"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
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 wg sync.WaitGroup
go func() {
<-ctx.Done()
_ = ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
wg.Wait()
return nil
}
continue
}
wg.Add(1)
go func() {
defer wg.Done()
handleDaemonConn(ctx, cancel, registry, protocol.NewConnTransport(conn))
}()
}
}
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) {
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:
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
}
if attach.TermSize.Cols > 0 && attach.TermSize.Rows > 0 {
project.Session.ResizeAll(attach.TermSize.Cols, attach.TermSize.Rows)
project.Launcher.SetSize(attach.TermSize.Cols, attach.TermSize.Rows)
project.Host.SetSize(attach.TermSize.Cols, attach.TermSize.Rows)
}
view := ClientView{
ID: fmt.Sprintf("c-%d", time.Now().UnixNano()),
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)
}
sub := newClientSubscriber(project.Key, defaultClientSubscriberQueue)
project.Session.SubscribeClient(sub)
defer project.Session.UnsubscribeClient(sub)
_ = sendHello(t, project, view.ID)
_ = sendProjectList(t, registry, project.Key)
_ = sendChrome(t, project, view)
if view.FocusedID != "" {
_ = sendSnapshot(t, project, view.FocusedID)
}
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 {
project.Session.ResizeAll(msg.Size.Cols, msg.Size.Rows)
project.Launcher.SetSize(msg.Size.Cols, msg.Size.Rows)
project.Host.SetSize(msg.Size.Cols, msg.Size.Rows)
}
case protocol.FrameFocus:
msg, err := protocol.Decode[protocol.Focus](f)
if err == nil && msg.PaneID != "" {
view.FocusChild(msg.PaneID)
_ = sendChrome(t, project, view)
_ = sendSnapshot(t, project, msg.PaneID)
}
case protocol.FramePaletteCommand:
if child := handleDaemonPaletteCommand(project, f); child != nil {
view.FocusChild(child.ID)
_ = sendChrome(t, project, view)
_ = sendSnapshot(t, project, 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, paneID string) error {
b, err := p.Session.SerializeChild(paneID)
if err != nil {
return nil
}
f, err := protocol.NewFrame(protocol.FramePaneSnapshot, protocol.PaneSnapshot{PaneID: paneID, Bytes: b})
if err != nil {
return err
}
return t.Send(f)
}
func sendProtocolError(t protocol.Transport, msg string) error {
f, err := protocol.NewFrame(protocol.FrameError, protocol.Error{Message: msg})
if err != nil {
return err
}
return t.Send(f)
}

View File

@@ -0,0 +1,213 @@
package app
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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 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 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 recvFrameWithin(tr protocol.Transport, timeout time.Duration) (protocol.Frame, error, bool) {
type result struct {
f protocol.Frame
err error
}
ch := make(chan result, 1)
go func() {
f, err := tr.Recv()
ch <- result{f: f, err: err}
}()
select {
case r := <-ch:
return r.f, r.err, true
case <-time.After(timeout):
return protocol.Frame{}, nil, false
}
}
func chromeHasProcess(chrome protocol.Chrome, name string) bool {
var model struct {
Processes []childModel `json:"processes"`
}
if err := json.Unmarshal(chrome.Model, &model); err != nil {
return false
}
for _, p := range model.Processes {
if p.Name == name {
return true
}
}
return false
}

View File

@@ -153,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 {

View File

@@ -32,6 +32,9 @@ const (
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
@@ -72,9 +75,10 @@ type Hello struct {
}
type Attach struct {
Token string `json:"token,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
TermSize Size `json:"term_size"`
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 {
@@ -162,3 +166,7 @@ type TrustResponse struct {
type Resize struct {
Size Size `json:"size"`
}
type Error struct {
Message string `json:"message"`
}