app: add loopback multi-project registry

This commit is contained in:
2026-05-27 13:40:59 +01:00
parent 08c7405c79
commit 80a14502c4
9 changed files with 798 additions and 125 deletions

View File

@@ -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=<dir> is set, write a verbose log
// (patterm.log), per-child raw PTY output (<id>.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.