4 Commits

18 changed files with 890 additions and 44 deletions

View File

@@ -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)
}

View File

@@ -161,6 +161,21 @@ func Run(ctx context.Context, opts Options) error {
// 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,
}
_ = core
st := &uiState{
sess: sess,
presets: presets,
@@ -171,6 +186,12 @@ func Run(ctx context.Context, opts Options) error {
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,8 +1418,11 @@ func (st *uiState) renderEmptyState() {
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
st.dimsMu.Lock()
defer st.dimsMu.Unlock()
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 {
st.dimsMu.Lock()
@@ -1397,8 +1431,11 @@ func (st *uiState) layoutSnapshot() terminalLayout {
}
func (st *uiState) layoutLocked() terminalLayout {
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
// its own slice, with the surrounding non-Enter bytes batched between.
@@ -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()
}

View File

@@ -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

View 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()),
}
}

View 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)
}
}

View 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
}

View 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")
}
}

View 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
}

View 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
}

View File

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

View File

@@ -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)

View File

@@ -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

164
internal/protocol/frame.go Normal file
View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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()
}

View File

@@ -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

84
internal/pty/pty_test.go Normal file
View File

@@ -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)
}