Persistent daemon + thin networked client #9
@@ -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()
|
||||
}
|
||||
|
||||
78
internal/app/chrome_model.go
Normal file
78
internal/app/chrome_model.go
Normal file
@@ -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()),
|
||||
}
|
||||
}
|
||||
24
internal/app/chrome_model_test.go
Normal file
24
internal/app/chrome_model_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
122
internal/app/client_subscriber.go
Normal file
122
internal/app/client_subscriber.go
Normal file
@@ -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
|
||||
}
|
||||
32
internal/app/client_subscriber_test.go
Normal file
32
internal/app/client_subscriber_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
39
internal/app/client_view.go
Normal file
39
internal/app/client_view.go
Normal file
@@ -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
|
||||
}
|
||||
29
internal/app/daemon_core.go
Normal file
29
internal/app/daemon_core.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user