From 9aecc8b7a26767d67a840b63c62eec1508931aa9 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 13:19:56 +0100 Subject: [PATCH] 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 {