app: add loopback multi-project registry
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user