From b72a32bbc6f19223da7993a38db6f7c28ff6aaf5 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:19:35 +0100 Subject: [PATCH 01/14] Fix PTY workdir and process group teardown --- internal/app/child.go | 2 +- internal/pty/pty.go | 9 ++++- internal/pty/pty_test.go | 84 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 internal/pty/pty_test.go 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/pty/pty.go b/internal/pty/pty.go index 4f8beca..925a6b0 100644 --- a/internal/pty/pty.go +++ b/internal/pty/pty.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "syscall" cpty "github.com/creack/pty" ) @@ -19,11 +20,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 { @@ -88,6 +91,10 @@ func (p *PTY) Close() error { 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) +} -- 2.49.1 From e63bdad5e175301cc692e5577fa3e050d5adff2d Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:19:42 +0100 Subject: [PATCH 02/14] Add daemon client protocol frames --- internal/protocol/frame.go | 164 +++++++++++++++++++++++++++++ internal/protocol/loopback.go | 67 ++++++++++++ internal/protocol/loopback_test.go | 51 +++++++++ internal/protocol/transport.go | 73 +++++++++++++ 4 files changed, 355 insertions(+) create mode 100644 internal/protocol/frame.go create mode 100644 internal/protocol/loopback.go create mode 100644 internal/protocol/loopback_test.go create mode 100644 internal/protocol/transport.go diff --git a/internal/protocol/frame.go b/internal/protocol/frame.go new file mode 100644 index 0000000..26e5964 --- /dev/null +++ b/internal/protocol/frame.go @@ -0,0 +1,164 @@ +// 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" +) + +// 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"` + 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"` +} + +type PaneChunk struct { + PaneID string `json:"pane_id"` + Bytes []byte `json:"bytes"` +} + +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"` +} 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..8d431b5 --- /dev/null +++ b/internal/protocol/transport.go @@ -0,0 +1,73 @@ +package protocol + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net" +) + +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. +type ConnTransport struct { + conn net.Conn + r *bufio.Reader + 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) + } + 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() +} -- 2.49.1 From 9aecc8b7a26767d67a840b63c62eec1508931aa9 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:19:56 +0100 Subject: [PATCH 03/14] Scaffold loopback daemon client split --- internal/app/app.go | 108 ++++++++++++++-------- internal/app/chrome_model.go | 78 ++++++++++++++++ internal/app/chrome_model_test.go | 24 +++++ internal/app/client_subscriber.go | 122 +++++++++++++++++++++++++ internal/app/client_subscriber_test.go | 32 +++++++ internal/app/client_view.go | 39 ++++++++ internal/app/daemon_core.go | 29 ++++++ internal/app/session.go | 40 ++++++++ 8 files changed, 436 insertions(+), 36 deletions(-) create mode 100644 internal/app/chrome_model.go create mode 100644 internal/app/chrome_model_test.go create mode 100644 internal/app/client_subscriber.go create mode 100644 internal/app/client_subscriber_test.go create mode 100644 internal/app/client_view.go create mode 100644 internal/app/daemon_core.go diff --git a/internal/app/app.go b/internal/app/app.go index a4a9339..3b6757a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -161,16 +161,37 @@ func Run(ctx context.Context, opts Options) error { // ctx is cancelled. go sess.runClassifier(ctx) - st := &uiState{ - sess: sess, + core := &headlessCore{ + projectDir: opts.ProjectDir, + projectKey: opts.ProjectKey, presets: presets, - launcher: launcher, + settings: appSettings, pads: pads, - chromeWake: make(chan struct{}, 1), - trust: trustStore, - timers: host.timers, - hostCols: cols, - hostRows: rows, + trustStore: trustStore, + persistStore: persistStore, + mcpSrv: mcpSrv, + sess: sess, + launcher: launcher, + host: host, + } + _ = core + + st := &uiState{ + sess: sess, + presets: presets, + launcher: launcher, + pads: pads, + chromeWake: make(chan struct{}, 1), + trust: trustStore, + timers: host.timers, + hostCols: cols, + hostRows: rows, + view: ClientView{ + ID: "loopback", + ProjectKey: opts.ProjectKey, + Cols: cols, + Rows: rows, + }, stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), metrics: metrics, settings: appSettings, @@ -252,6 +273,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() @@ -408,6 +430,7 @@ type uiState struct { outMu sync.Mutex mu sync.Mutex + view ClientView palette *paletteState focusedID string focusedName string @@ -574,6 +597,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 +624,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 +687,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 +742,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 +777,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 +791,7 @@ func (st *uiState) updateActiveAgentLocked(c *Child) { } if root.Kind == KindAgent && root.ParentID == "" { st.activeAgentID = root.ID + st.view.ActiveAgentID = root.ID } } @@ -822,9 +854,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 +929,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 +941,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() @@ -1387,7 +1418,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 +1431,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 @@ -2086,9 +2123,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() @@ -2232,13 +2267,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 +2279,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 +2311,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 +2582,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 +2640,7 @@ func (st *uiState) exitPadView() { return } st.focusedPad = "" + st.view.FocusedPad = "" st.focusedName = "" st.mu.Unlock() st.clearViewportArea() @@ -2632,6 +2667,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/chrome_model.go b/internal/app/chrome_model.go new file mode 100644 index 0000000..0504635 --- /dev/null +++ b/internal/app/chrome_model.go @@ -0,0 +1,78 @@ +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"` + 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, + 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_subscriber.go b/internal/app/client_subscriber.go new file mode 100644 index 0000000..c8c728f --- /dev/null +++ b/internal/app/client_subscriber.go @@ -0,0 +1,122 @@ +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 + frames chan protocol.Frame + + mu sync.Mutex + snapshotRequired map[string]bool + lifecycleDirty bool +} + +func newClientSubscriber(projectKey string, size int) *clientSubscriber { + if size <= 0 { + size = defaultClientSubscriberQueue + } + return &clientSubscriber{ + projectKey: projectKey, + 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...) + f, err := protocol.NewFrame(protocol.FramePaneChunk, protocol.PaneChunk{PaneID: childID, Bytes: cp}) + 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..e50fe72 --- /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", 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..4f9d88c --- /dev/null +++ b/internal/app/client_view.go @@ -0,0 +1,39 @@ +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 + 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..33b3ca6 --- /dev/null +++ b/internal/app/daemon_core.go @@ -0,0 +1,29 @@ +package app + +import ( + "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" +) + +// headlessCore is the daemon-owned half of today's single-process app. It is +// intentionally small for the foundation phase: it groups process/project +// state while the existing loopback client still renders in-process. +type headlessCore struct { + projectDir string + projectKey string + + presets preset.Set + settings settings + + pads *scratchpad.Store + trustStore *trust.Store + persistStore *persist.Store + + mcpSrv *mcp.Server + sess *Session + launcher *Launcher + host *toolHost +} diff --git a/internal/app/session.go b/internal/app/session.go index b7851f3..fabcc4f 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) { @@ -146,16 +163,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 +196,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 { -- 2.49.1 From ec0c14816445bdeca76d2a285838097d5bc70184 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:20:40 +0100 Subject: [PATCH 04/14] Update PTY start call sites --- cmd/spike/main.go | 2 +- internal/harness/restart_persist_test.go | 8 ++++---- internal/harness/session.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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/internal/harness/restart_persist_test.go b/internal/harness/restart_persist_test.go index c278df5..658ed5b 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, "--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..94cdf56 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, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) if err != nil { _ = em.Close() return nil, err -- 2.49.1 From 08c7405c7924aa2fbf7282b493bb9bcfdbab9aed Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:25:59 +0100 Subject: [PATCH 05/14] docs: add daemon client implementation plan --- docs/daemon-client-plan.md | 266 +++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/daemon-client-plan.md diff --git a/docs/daemon-client-plan.md b/docs/daemon-client-plan.md new file mode 100644 index 0000000..54d9693 --- /dev/null +++ b/docs/daemon-client-plan.md @@ -0,0 +1,266 @@ +# patterm: persistent daemon + thin networked client — implementation plan + +Status: proposed (for peer review). Branch: `feat/daemon-client-split`. + +## 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? -- 2.49.1 From 80a14502c4ca543cc310bc2115013476b98a669e Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:40:59 +0100 Subject: [PATCH 06/14] app: add loopback multi-project registry --- CHANGELOG.md | 7 + internal/app/app.go | 295 +++++++++++------ internal/app/chrome_model.go | 2 + internal/app/client_view.go | 1 + internal/app/daemon_core.go | 450 +++++++++++++++++++++++++- internal/app/palette.go | 62 +++- internal/app/project_registry_test.go | 100 ++++++ internal/app/session.go | 3 + internal/mcp/mcp.go | 3 + 9 files changed, 798 insertions(+), 125 deletions(-) create mode 100644 internal/app/project_registry_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc40a4..e557347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- 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. diff --git a/internal/app/app.go b/internal/app/app.go index 3b6757a..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,41 +97,43 @@ 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) - - core := &headlessCore{ - projectDir: opts.ProjectDir, - projectKey: opts.ProjectKey, - presets: presets, - settings: appSettings, - pads: pads, - trustStore: trustStore, - persistStore: persistStore, - mcpSrv: mcpSrv, - sess: sess, - launcher: launcher, - host: host, + 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) } - _ = core st := &uiState{ - sess: sess, + registry: registry, + project: project, + sess: project.Session, presets: presets, - launcher: launcher, - pads: pads, + launcher: project.Launcher, + pads: project.Pads, chromeWake: make(chan struct{}, 1), - trust: trustStore, - timers: host.timers, + trust: project.Trust, + timers: project.Host.timers, hostCols: cols, hostRows: rows, view: ClientView{ - ID: "loopback", - ProjectKey: opts.ProjectKey, - Cols: cols, - Rows: rows, + ID: "loopback", + ProjectKey: project.Key, + ProjectName: project.Name, + Cols: cols, + Rows: rows, }, stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), metrics: metrics, @@ -198,7 +141,7 @@ func Run(ctx context.Context, opts Options) error { 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() @@ -210,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() @@ -227,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 @@ -281,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() @@ -420,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 @@ -532,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...) } @@ -1300,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) @@ -1328,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 != "" { @@ -2003,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 @@ -2138,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. diff --git a/internal/app/chrome_model.go b/internal/app/chrome_model.go index 0504635..cbbf74c 100644 --- a/internal/app/chrome_model.go +++ b/internal/app/chrome_model.go @@ -6,6 +6,7 @@ import "github.com/hjbdev/patterm/internal/scratchpad" // 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"` @@ -41,6 +42,7 @@ func buildChromeModel(projectKey string, view ClientView, children []*Child, pad } model := chromeModel{ ProjectKey: projectKey, + ProjectName: view.ProjectName, FocusedID: view.FocusedID, FocusedPad: view.FocusedPad, ActiveAgentID: active, diff --git a/internal/app/client_view.go b/internal/app/client_view.go index 4f9d88c..ba7939f 100644 --- a/internal/app/client_view.go +++ b/internal/app/client_view.go @@ -6,6 +6,7 @@ package app type ClientView struct { ID string ProjectKey string + ProjectName string FocusedID string FocusedPad string ActiveAgentID string diff --git a/internal/app/daemon_core.go b/internal/app/daemon_core.go index 33b3ca6..a5e145b 100644 --- a/internal/app/daemon_core.go +++ b/internal/app/daemon_core.go @@ -1,29 +1,447 @@ 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/scratchpad" "github.com/hjbdev/patterm/internal/trust" ) -// headlessCore is the daemon-owned half of today's single-process app. It is -// intentionally small for the foundation phase: it groups process/project -// state while the existing loopback client still renders in-process. -type headlessCore struct { - projectDir string - projectKey string +type Project struct { + Key string + Dir string + Name string - presets preset.Set - settings settings + Session *Session + Pads *scratchpad.Store + Trust *trust.Store + Persist *persist.Store + Launcher *Launcher + Host *toolHost + savedProcess []persist.Entry - pads *scratchpad.Store - trustStore *trust.Store - persistStore *persist.Store - - mcpSrv *mcp.Server - sess *Session - launcher *Launcher - host *toolHost + lastActive time.Time +} + +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, + 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) 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() ([]scratchpad.Entry, error) { + return r.hostForCaller("").ScratchpadList() +} + +func (r *ProjectRegistry) ScratchpadRead(name string) (string, string, error) { + return r.hostForCaller("").ScratchpadRead(name) +} + +func (r *ProjectRegistry) ScratchpadWrite(name, content, expectedRevision string) (string, error) { + return r.hostForCaller("").ScratchpadWrite(name, content, expectedRevision) +} + +func (r *ProjectRegistry) ScratchpadAppend(name, content string) error { + return r.hostForCaller("").ScratchpadAppend(name, content) +} + +func (r *ProjectRegistry) ScratchpadDelete(name string) error { + return r.hostForCaller("").ScratchpadDelete(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/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..a26f000 --- /dev/null +++ b/internal/app/project_registry_test.go @@ -0,0 +1,100 @@ +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") + } +} diff --git a/internal/app/session.go b/internal/app/session.go index fabcc4f..df8d899 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -266,6 +266,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() 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 { -- 2.49.1 From c56de27f443688b7b4254ce3dcfd215a7096d711 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:50:17 +0100 Subject: [PATCH 07/14] fix scratchpad routing by caller project --- internal/app/daemon_core.go | 26 ++++++----- internal/app/host.go | 10 ++--- internal/app/project_registry_test.go | 62 ++++++++++++++++++++++++++ internal/app/scratchpad_delete_test.go | 4 +- internal/mcp/mcp_test.go | 14 +++--- internal/mcp/tools.go | 20 ++++----- 6 files changed, 102 insertions(+), 34 deletions(-) diff --git a/internal/app/daemon_core.go b/internal/app/daemon_core.go index a5e145b..0154f3f 100644 --- a/internal/app/daemon_core.go +++ b/internal/app/daemon_core.go @@ -150,6 +150,12 @@ func (r *ProjectRegistry) Count() int { return len(r.projects) } +func (r *ProjectRegistry) DefaultProject() *Project { + r.mu.Lock() + defer r.mu.Unlock() + return r.projects[r.defaultProjectKey] +} + func (r *ProjectRegistry) Shutdown() { r.mu.Lock() projects := make([]*Project, 0, len(r.projects)) @@ -418,24 +424,24 @@ func (r *ProjectRegistry) TimerList(callerID string) ([]mcp.TimerInfo, error) { return r.hostForCaller(callerID).TimerList(callerID) } -func (r *ProjectRegistry) ScratchpadList() ([]scratchpad.Entry, error) { - return r.hostForCaller("").ScratchpadList() +func (r *ProjectRegistry) ScratchpadList(callerID string) ([]scratchpad.Entry, error) { + return r.hostForCaller(callerID).ScratchpadList(callerID) } -func (r *ProjectRegistry) ScratchpadRead(name string) (string, string, error) { - return r.hostForCaller("").ScratchpadRead(name) +func (r *ProjectRegistry) ScratchpadRead(callerID, name string) (string, string, error) { + return r.hostForCaller(callerID).ScratchpadRead(callerID, name) } -func (r *ProjectRegistry) ScratchpadWrite(name, content, expectedRevision string) (string, error) { - return r.hostForCaller("").ScratchpadWrite(name, content, expectedRevision) +func (r *ProjectRegistry) ScratchpadWrite(callerID, name, content, expectedRevision string) (string, error) { + return r.hostForCaller(callerID).ScratchpadWrite(callerID, name, content, expectedRevision) } -func (r *ProjectRegistry) ScratchpadAppend(name, content string) error { - return r.hostForCaller("").ScratchpadAppend(name, content) +func (r *ProjectRegistry) ScratchpadAppend(callerID, name, content string) error { + return r.hostForCaller(callerID).ScratchpadAppend(callerID, name, content) } -func (r *ProjectRegistry) ScratchpadDelete(name string) error { - return r.hostForCaller("").ScratchpadDelete(name) +func (r *ProjectRegistry) ScratchpadDelete(callerID, name string) error { + return r.hostForCaller(callerID).ScratchpadDelete(callerID, name) } func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI { 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/project_registry_test.go b/internal/app/project_registry_test.go index a26f000..3df2092 100644 --- a/internal/app/project_registry_test.go +++ b/internal/app/project_registry_test.go @@ -98,3 +98,65 @@ func TestSwitchProjectPreservesProjectProcessTrees(t *testing.T) { 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/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 -- 2.49.1 From d07a09d64fd2e21f73c20d838d292efb54064cbd Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:55:38 +0100 Subject: [PATCH 08/14] add local daemon socket protocol --- CHANGELOG.md | 8 + cmd/patterm/main.go | 84 +++++++ internal/app/daemon_net.go | 375 ++++++++++++++++++++++++++++++++ internal/app/daemon_net_test.go | 213 ++++++++++++++++++ internal/app/session.go | 18 ++ internal/protocol/frame.go | 14 +- 6 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 internal/app/daemon_net.go create mode 100644 internal/app/daemon_net_test.go 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"` +} -- 2.49.1 From 95b1967e9bc2e40ee64818f94e48752e9a040d2d Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:59:47 +0100 Subject: [PATCH 09/14] Fix daemon shutdown hang and concurrent-send race - daemon_net: close the client transport on context cancellation so the per-connection Recv loop unblocks; otherwise wg.Wait() in the accept loop hung on a still-connected client and the daemon never stopped. - protocol: guard ConnTransport.Send with a mutex so the subscriber pump and command handlers can push frames concurrently without racing the bufio.Writer. Fixes TestDaemonDetachReattachPreservesProcess (now passes under -race). --- internal/app/daemon_net.go | 8 ++++++++ internal/protocol/transport.go | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/app/daemon_net.go b/internal/app/daemon_net.go index 48b0f17..2e7a2a4 100644 --- a/internal/app/daemon_net.go +++ b/internal/app/daemon_net.go @@ -232,6 +232,14 @@ func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protoc _ = sendSnapshot(t, project, 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) diff --git a/internal/protocol/transport.go b/internal/protocol/transport.go index 8d431b5..45bf531 100644 --- a/internal/protocol/transport.go +++ b/internal/protocol/transport.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net" + "sync" ) var ErrTransportClosed = errors.New("protocol: transport closed") @@ -18,10 +19,14 @@ type Transport interface { Close() error } -// ConnTransport is a JSON-lines implementation over a stream connection. +// 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 } @@ -41,6 +46,8 @@ func (t *ConnTransport) Send(f Frame) error { 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 } -- 2.49.1 From 5149224000372b234ef70f767cfb1aafcb52d791 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 14:09:51 +0100 Subject: [PATCH 10/14] attach default client to local daemon --- CHANGELOG.md | 4 + cmd/patterm/main.go | 28 +- internal/app/client_net.go | 637 +++++++++++++++++++++++ internal/app/client_net_test.go | 157 ++++++ internal/harness/restart_persist_test.go | 2 +- internal/harness/session.go | 2 +- 6 files changed, 824 insertions(+), 6 deletions(-) create mode 100644 internal/app/client_net.go create mode 100644 internal/app/client_net_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 16835bf..5c574ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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 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 diff --git a/cmd/patterm/main.go b/cmd/patterm/main.go index b76ae57..82e4270 100644 --- a/cmd/patterm/main.go +++ b/cmd/patterm/main.go @@ -64,6 +64,7 @@ func main() { 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)") ) @@ -84,6 +85,8 @@ func main() { } if *projectDir != "" { cwd = *projectDir + } else if flag.NArg() > 0 { + cwd = flag.Arg(0) } key, err := projectkey.Key(cwd) if err != nil { @@ -107,11 +110,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) } @@ -223,6 +241,8 @@ func runDaemonCommand() { } if *projectDir != "" { cwd = *projectDir + } else if flag.NArg() > 0 { + cwd = flag.Arg(0) } if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil { die("daemon: %v", err) diff --git a/internal/app/client_net.go b/internal/app/client_net.go new file mode 100644 index 0000000..9169477 --- /dev/null +++ b/internal/app/client_net.go @@ -0,0 +1,637 @@ +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 + 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 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 + layout terminalLayout + + mu sync.Mutex + focusedID string + 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, + 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(), + 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.renderer = newViewportRenderer(c.layout) + renderer := c.renderer + c.mu.Unlock() + c.clearViewport() + 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.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.layout) + } + 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) 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) + } + 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/harness/restart_persist_test.go b/internal/harness/restart_persist_test.go index 658ed5b..a7460b8 100644 --- a/internal/harness/restart_persist_test.go +++ b/internal/harness/restart_persist_test.go @@ -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 94cdf56..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 -- 2.49.1 From 63cb8a438846acbd014ae6db2fe172ed210e91a2 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 14:19:14 +0100 Subject: [PATCH 11/14] add tcp daemon listener with token auth --- CHANGELOG.md | 8 +++ cmd/patterm/main.go | 70 ++++++++++++++++++- internal/app/client_net.go | 12 ++++ internal/app/daemon_net.go | 109 ++++++++++++++++++++++++++--- internal/app/daemon_net_test.go | 118 ++++++++++++++++++++++++++++++++ internal/app/token.go | 63 +++++++++++++++++ 6 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 internal/app/token.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c574ce..94c725c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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. - 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 diff --git a/cmd/patterm/main.go b/cmd/patterm/main.go index 82e4270..7709ad3 100644 --- a/cmd/patterm/main.go +++ b/cmd/patterm/main.go @@ -56,6 +56,11 @@ func main() { 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 @@ -233,7 +238,10 @@ func runDaemonCommand() { runDaemonList() return } - var projectDir = flag.String("project", "", "initial project directory (default $PWD)") + 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 { @@ -244,11 +252,69 @@ func runDaemonCommand() { } else if flag.NArg() > 0 { cwd = flag.Arg(0) } - if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil { + 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 { diff --git a/internal/app/client_net.go b/internal/app/client_net.go index 9169477..033bd84 100644 --- a/internal/app/client_net.go +++ b/internal/app/client_net.go @@ -33,6 +33,7 @@ type ClientOptions struct { Stdout io.Writer RawMode bool AutoStart bool + Token string Cols uint16 Rows uint16 } @@ -66,6 +67,14 @@ func RunAttachedClient(ctx context.Context, opts ClientOptions) error { 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 { @@ -120,6 +129,7 @@ type netClient struct { out io.Writer raw bool projectDir string + token string layout terminalLayout mu sync.Mutex @@ -141,6 +151,7 @@ func newNetClient(opts ClientOptions) *netClient { out: opts.Stdout, raw: opts.RawMode, projectDir: opts.ProjectDir, + token: opts.Token, layout: layout, renderer: newViewportRenderer(layout), } @@ -204,6 +215,7 @@ func (c *netClient) run(ctx context.Context) error { 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(), diff --git a/internal/app/daemon_net.go b/internal/app/daemon_net.go index 2e7a2a4..4163bd3 100644 --- a/internal/app/daemon_net.go +++ b/internal/app/daemon_net.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "os" "path/filepath" @@ -20,11 +21,15 @@ import ( ) type DaemonOptions struct { - ProjectDir string - SocketPath string - PidPath string - Cols uint16 - Rows uint16 + ProjectDir string + SocketPath string + PidPath string + ListenAddr string + Token string + TokenOut io.Writer + ListenReady chan string + Cols uint16 + Rows uint16 } type DaemonStatus struct { @@ -113,28 +118,101 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error { 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 { - wg.Wait() - return nil + return } - continue + select { + case errCh <- err: + default: + } + return } wg.Add(1) go func() { defer wg.Done() - handleDaemonConn(ctx, cancel, registry, protocol.NewConnTransport(conn)) + 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 @@ -163,7 +241,7 @@ func syscallSignal0(pid int) error { return syscall.Kill(pid, 0) } -func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport) { +func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport, authToken string) { defer t.Close() f, err := t.Recv() if err != nil { @@ -178,6 +256,17 @@ func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistr 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)) diff --git a/internal/app/daemon_net_test.go b/internal/app/daemon_net_test.go index d8f439e..5060af9 100644 --- a/internal/app/daemon_net_test.go +++ b/internal/app/daemon_net_test.go @@ -3,6 +3,7 @@ package app import ( "context" "encoding/json" + "io" "net" "os" "path/filepath" @@ -85,6 +86,80 @@ func TestDaemonDetachReattachPreservesProcess(t *testing.T) { } } +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 waitForSocket(t *testing.T, socket string, errCh <-chan error) { t.Helper() deadline := time.Now().Add(3 * time.Second) @@ -114,6 +189,49 @@ func dialDaemon(t *testing.T, socket string) protocol.Transport { 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) 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 +} -- 2.49.1 From 6d15626e0514c15edb3bdf9a287adbc81f1516bc Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 14:30:47 +0100 Subject: [PATCH 12/14] add per-pane display ownership --- CHANGELOG.md | 4 + internal/app/client_net.go | 32 +++++- internal/app/client_subscriber.go | 17 ++- internal/app/client_subscriber_test.go | 2 +- internal/app/daemon_core.go | 99 +++++++++++++++-- internal/app/daemon_net.go | 41 ++++--- internal/app/daemon_net_test.go | 146 +++++++++++++++++++++++++ internal/app/session.go | 16 +++ internal/protocol/frame.go | 12 +- 9 files changed, 333 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c725c..7967163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 diff --git a/internal/app/client_net.go b/internal/app/client_net.go index 033bd84..ad36de4 100644 --- a/internal/app/client_net.go +++ b/internal/app/client_net.go @@ -134,6 +134,8 @@ type netClient struct { mu sync.Mutex focusedID string + paneSize protocol.Size + ownerView bool chrome chromeModel renderer *viewportRenderer palette *clientCommandPrompt @@ -287,10 +289,13 @@ func (c *netClient) handleFrame(f protocol.Frame) error { } c.mu.Lock() c.focusedID = msg.PaneID - c.renderer = newViewportRenderer(c.layout) + 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) @@ -300,6 +305,11 @@ func (c *netClient) handleFrame(f protocol.Frame) error { 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)) @@ -508,7 +518,7 @@ func (c *netClient) resize(cols, rows uint16) error { c.mu.Lock() c.layout = newTerminalLayout(cols, rows) if c.renderer != nil { - c.renderer.SetLayout(c.layout) + c.renderer.SetLayout(c.renderLayoutLocked(c.paneSize)) } size := protocol.Size{Cols: c.layout.childCols(), Rows: c.layout.childRows()} c.mu.Unlock() @@ -519,6 +529,17 @@ func (c *netClient) resize(cols, rows uint16) error { 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() @@ -589,6 +610,13 @@ func (c *netClient) drawChrome() { 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) } diff --git a/internal/app/client_subscriber.go b/internal/app/client_subscriber.go index c8c728f..b22b4e4 100644 --- a/internal/app/client_subscriber.go +++ b/internal/app/client_subscriber.go @@ -15,6 +15,8 @@ const defaultClientSubscriberQueue = 256 // needing a fresh snapshot. type clientSubscriber struct { projectKey string + project *Project + clientID string frames chan protocol.Frame mu sync.Mutex @@ -22,12 +24,18 @@ type clientSubscriber struct { lifecycleDirty bool } -func newClientSubscriber(projectKey string, size int) *clientSubscriber { +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, @@ -72,7 +80,12 @@ func (s *clientSubscriber) OnChildStateChanged(id string, state IdleState) { func (s *clientSubscriber) OnPTYOut(childID string, chunk []byte) { cp := append([]byte(nil), chunk...) - f, err := protocol.NewFrame(protocol.FramePaneChunk, protocol.PaneChunk{PaneID: childID, Bytes: cp}) + 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 } diff --git a/internal/app/client_subscriber_test.go b/internal/app/client_subscriber_test.go index e50fe72..d9b325c 100644 --- a/internal/app/client_subscriber_test.go +++ b/internal/app/client_subscriber_test.go @@ -7,7 +7,7 @@ import ( ) func TestClientSubscriberCopiesChunksAndMarksSnapshotOnOverflow(t *testing.T) { - sub := newClientSubscriber("project", 1) + sub := newClientSubscriber(&Project{Key: "project"}, "client", 1) chunk := []byte("first") sub.OnPTYOut("p_123456", chunk) chunk[0] = 'X' diff --git a/internal/app/daemon_core.go b/internal/app/daemon_core.go index 0154f3f..6a8f350 100644 --- a/internal/app/daemon_core.go +++ b/internal/app/daemon_core.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -30,9 +31,17 @@ type Project struct { 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 @@ -111,17 +120,18 @@ func (r *ProjectRegistry) Open(ctx context.Context, dir string) (*Project, error 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, - lastActive: time.Now(), + 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() @@ -156,6 +166,73 @@ func (r *ProjectRegistry) DefaultProject() *Project { 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)) diff --git a/internal/app/daemon_net.go b/internal/app/daemon_net.go index 4163bd3..5619759 100644 --- a/internal/app/daemon_net.go +++ b/internal/app/daemon_net.go @@ -294,14 +294,9 @@ func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protoc _ = 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) - } - + clientID := fmt.Sprintf("c-%d", time.Now().UnixNano()) view := ClientView{ - ID: fmt.Sprintf("c-%d", time.Now().UnixNano()), + ID: clientID, ProjectKey: project.Key, ProjectName: project.Name, Cols: attach.TermSize.Cols, @@ -309,16 +304,18 @@ func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protoc } if child := firstRunningTopLevel(project.Session.Children()); child != nil { view.FocusChild(child.ID) + project.ClaimPaneDisplay(clientID, child.ID, attach.TermSize) } - sub := newClientSubscriber(project.Key, defaultClientSubscriberQueue) + 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, view.FocusedID) + _ = sendSnapshot(t, project, clientID, view.FocusedID) } // Close the transport when the daemon context is cancelled (shutdown or @@ -361,22 +358,28 @@ func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protoc 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) + 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, msg.PaneID) + _ = 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, child.ID) + _ = sendSnapshot(t, project, clientID, child.ID) } } select { @@ -451,12 +454,18 @@ func sendChrome(t protocol.Transport, p *Project, view ClientView) error { return t.Send(f) } -func sendSnapshot(t protocol.Transport, p *Project, paneID string) error { +func sendSnapshot(t protocol.Transport, p *Project, clientID, 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}) + 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 } diff --git a/internal/app/daemon_net_test.go b/internal/app/daemon_net_test.go index 5060af9..98978c2 100644 --- a/internal/app/daemon_net_test.go +++ b/internal/app/daemon_net_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/protocol" ) @@ -160,6 +161,76 @@ func TestDaemonTCPTokenAuthAndUnixExemption(t *testing.T) { } } +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) @@ -297,6 +368,81 @@ func waitForLifecycle(t *testing.T, tr protocol.Transport, kind protocol.Lifecyc 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 diff --git a/internal/app/session.go b/internal/app/session.go index 9f0af47..155f1f6 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -742,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/protocol/frame.go b/internal/protocol/frame.go index 15a4136..982297c 100644 --- a/internal/protocol/frame.go +++ b/internal/protocol/frame.go @@ -108,13 +108,17 @@ type Chrome struct { } type PaneSnapshot struct { - PaneID string `json:"pane_id"` - Bytes []byte `json:"bytes"` + 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"` + PaneID string `json:"pane_id"` + Bytes []byte `json:"bytes"` + Size Size `json:"size,omitempty"` + DisplayOwner bool `json:"display_owner,omitempty"` } type LifecycleKind string -- 2.49.1 From 63986e7e008725cd28cc771a5541b99a13124805 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 14:35:33 +0100 Subject: [PATCH 13/14] Fix data race on PTY master between Read and Close pumpChild's PTY.Read raced Session.Shutdown's PTY.Close on the master field (Close set it nil while Read read it). Benign at process exit on main, but the daemon now runs Shutdown routinely (daemon stop). Guard the field with a mutex, capturing the fd under the lock and doing the blocking I/O outside it so Close still unblocks an in-flight Read. Caught under: go test -race -run 'Daemon|NetClient|Owner' -count=5. --- internal/pty/pty.go | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/internal/pty/pty.go b/internal/pty/pty.go index 925a6b0..f18c696 100644 --- a/internal/pty/pty.go +++ b/internal/pty/pty.go @@ -6,13 +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 } @@ -45,24 +54,33 @@ func Start(argv []string, env []string, workDir string, cols, rows uint16) (*PTY } 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. @@ -83,12 +101,15 @@ 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 -- 2.49.1 From 4051e7264b8a1cd1b8c7b2ba10c83d436b838553 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 14:36:02 +0100 Subject: [PATCH 14/14] docs: mark daemon/client plan as implemented --- docs/daemon-client-plan.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/daemon-client-plan.md b/docs/daemon-client-plan.md index 54d9693..ff81690 100644 --- a/docs/daemon-client-plan.md +++ b/docs/daemon-client-plan.md @@ -1,6 +1,13 @@ # patterm: persistent daemon + thin networked client — implementation plan -Status: proposed (for peer review). Branch: `feat/daemon-client-split`. +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 -- 2.49.1