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/protocol" "github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/trust" ) type Project struct { Key string Dir string Name string Session *Session Pads *scratchpad.Store Trust *trust.Store Persist *persist.Store Launcher *Launcher 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 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, displayOwners: make(map[string]paneDisplayOwner), 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) DefaultProject() *Project { r.mu.Lock() defer r.mu.Unlock() 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)) 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(callerID string) ([]scratchpad.Entry, error) { return r.hostForCaller(callerID).ScratchpadList(callerID) } func (r *ProjectRegistry) ScratchpadRead(callerID, name string) (string, string, error) { return r.hostForCaller(callerID).ScratchpadRead(callerID, name) } func (r *ProjectRegistry) ScratchpadWrite(callerID, name, content, expectedRevision string) (string, error) { return r.hostForCaller(callerID).ScratchpadWrite(callerID, name, content, expectedRevision) } func (r *ProjectRegistry) ScratchpadAppend(callerID, name, content string) error { return r.hostForCaller(callerID).ScratchpadAppend(callerID, name, content) } func (r *ProjectRegistry) ScratchpadDelete(callerID, name string) error { return r.hostForCaller(callerID).ScratchpadDelete(callerID, 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) }