diff --git a/CHANGELOG.md b/CHANGELOG.md index e557347..16835bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/cmd/patterm/main.go b/cmd/patterm/main.go index 3cd2dce..b76ae57 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,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 { diff --git a/internal/app/daemon_net.go b/internal/app/daemon_net.go new file mode 100644 index 0000000..48b0f17 --- /dev/null +++ b/internal/app/daemon_net.go @@ -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) +} diff --git a/internal/app/daemon_net_test.go b/internal/app/daemon_net_test.go new file mode 100644 index 0000000..d8f439e --- /dev/null +++ b/internal/app/daemon_net_test.go @@ -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 +} diff --git a/internal/app/session.go b/internal/app/session.go index df8d899..9f0af47 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -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 { diff --git a/internal/protocol/frame.go b/internal/protocol/frame.go index 26e5964..15a4136 100644 --- a/internal/protocol/frame.go +++ b/internal/protocol/frame.go @@ -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"` +}