Sync MCP surface to SPEC §7 process model
Rename list_children/read_output/kill/send_message_to to their SPEC §7 process_id-shaped names; drop report_to_parent (direction inferred by send_message) and policy_check (replaced by per-project trust gating). Add the SPEC's missing tools: start_process, restart_process, close_process, rename_process, select_process, get_process_status, get_project_status, get_process_raw_output, search_output, get_process_ports, whoami, help. Process model now distinguishes agent/terminal/command kinds with opaque p_<6hex> IDs. Command entries are session-persistent so they survive PTY exit and can be Restart'd. Status enum gains starting and stopped. screen_version, port detection, and bracketed-paste send_input land alongside. Trust gating (internal/trust) replaces the regex policy: command-preset spawns return needs_trust on first use; the user confirms in a status-line modal and the grant persists to \$XDG_DATA_HOME/patterm/projects/<key>/trust.json. Tests cover send_message direction inference (parent↔child, sibling rejection, nil caller paths) and trust grant persistence across reopen.
This commit is contained in:
@@ -16,9 +16,9 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/mcp"
|
||||
"github.com/harrybrwn/patterm/internal/policy"
|
||||
"github.com/harrybrwn/patterm/internal/preset"
|
||||
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||
"github.com/harrybrwn/patterm/internal/trust"
|
||||
)
|
||||
|
||||
// Options configures a patterm run.
|
||||
@@ -41,11 +41,6 @@ func Run(ctx context.Context, opts Options) error {
|
||||
return fmt.Errorf("app: load presets: %w", err)
|
||||
}
|
||||
|
||||
pol, err := policy.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("app: load policy: %w", 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)
|
||||
@@ -53,6 +48,12 @@ func Run(ctx context.Context, opts Options) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// In-process MCP server bound to the per-PID socket. Children that
|
||||
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
|
||||
// SPEC §10.
|
||||
@@ -76,7 +77,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||
// 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, pol, layout.childCols(), layout.childRows())
|
||||
host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows())
|
||||
mcpSrv.SetHost(host)
|
||||
|
||||
var restoreState *term.State
|
||||
@@ -96,11 +97,14 @@ func Run(ctx context.Context, opts Options) error {
|
||||
presets: presets,
|
||||
launcher: launcher,
|
||||
pads: pads,
|
||||
trust: trustStore,
|
||||
hostCols: cols,
|
||||
hostRows: rows,
|
||||
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
host.attention = st
|
||||
host.focus = st
|
||||
host.prompter = st
|
||||
st.lastExit.Store(-1)
|
||||
sess.Subscribe(st)
|
||||
|
||||
@@ -200,6 +204,7 @@ type uiState struct {
|
||||
presets preset.Set
|
||||
launcher *Launcher
|
||||
pads *scratchpad.Store
|
||||
trust *trust.Store
|
||||
|
||||
outMu sync.Mutex
|
||||
|
||||
@@ -220,6 +225,13 @@ type uiState struct {
|
||||
attentionText string
|
||||
attentionAt string
|
||||
|
||||
// pendingTrust is the most recent trust prompt — surfaced in the
|
||||
// status line until the user resolves it with Ctrl-K. v1 keeps the
|
||||
// confirmation modal minimal: the user opens the palette and picks
|
||||
// "Trust preset <name>" / "Deny preset <name>". A future iteration
|
||||
// can promote this to a dedicated inline modal.
|
||||
pendingTrust *trustRequest
|
||||
|
||||
dimsMu sync.Mutex
|
||||
hostCols, hostRows uint16
|
||||
stdinTTY bool
|
||||
@@ -231,6 +243,42 @@ func (st *uiState) dbgf(format string, args ...any) {
|
||||
logf(format, args...)
|
||||
}
|
||||
|
||||
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
|
||||
// to spawn / start / restart against an untrusted command preset and
|
||||
// the host wants user confirmation before the next attempt succeeds.
|
||||
type trustRequest struct {
|
||||
processID string
|
||||
presetName string
|
||||
reason string
|
||||
}
|
||||
|
||||
// promptTrust is the SPEC §7 trust gate UI hook. Replaces any prior
|
||||
// pending request — the most recent prompt wins.
|
||||
func (st *uiState) promptTrust(processID, presetName, reason string) {
|
||||
st.mu.Lock()
|
||||
st.pendingTrust = &trustRequest{processID: processID, presetName: presetName, reason: reason}
|
||||
st.mu.Unlock()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
c := st.sess.FindChild(processID)
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
||||
st.mu.Unlock()
|
||||
st.repaintFocused()
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
||||
// surface a one-line toast in the status row and remember the most
|
||||
// recent ask so the status line keeps showing it. The sidebar-blink is
|
||||
@@ -370,6 +418,10 @@ func (st *uiState) drawStatusLine() {
|
||||
focusName := st.focusedName
|
||||
attention := st.attentionText
|
||||
attentionAt := st.attentionAt
|
||||
var trustMsg string
|
||||
if st.pendingTrust != nil {
|
||||
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
|
||||
}
|
||||
st.mu.Unlock()
|
||||
if palOpen {
|
||||
return
|
||||
@@ -403,6 +455,13 @@ func (st *uiState) drawStatusLine() {
|
||||
if attention != "" && attentionAt == focusID {
|
||||
left = "[!] " + attention
|
||||
}
|
||||
if attention != "" && attentionAt == "" {
|
||||
// Sticky attention/flash from somewhere outside the focused pane.
|
||||
left = "[!] " + attention
|
||||
}
|
||||
if trustMsg != "" {
|
||||
left = "[trust] " + trustMsg
|
||||
}
|
||||
right := "Ctrl-K · palette"
|
||||
|
||||
pad := int(cols) - len(left) - len(right)
|
||||
@@ -490,6 +549,48 @@ func (st *uiState) stdinLoop() error {
|
||||
func (st *uiState) processStdin(chunk []byte) {
|
||||
st.mu.Lock()
|
||||
|
||||
// Trust modal is modal: y/Y accepts, n/N or ESC denies. Everything
|
||||
// else is ignored so a typo doesn't leak into the focused PTY while
|
||||
// the prompt is up. SPEC §7 trust gate.
|
||||
if st.pendingTrust != nil {
|
||||
req := *st.pendingTrust
|
||||
consumed := 0
|
||||
var resolved string
|
||||
for _, b := range chunk {
|
||||
consumed++
|
||||
switch b {
|
||||
case 'y', 'Y':
|
||||
resolved = "accept"
|
||||
case 'n', 'N', 0x1b: // ESC
|
||||
resolved = "deny"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if resolved != "" {
|
||||
st.pendingTrust = nil
|
||||
st.mu.Unlock()
|
||||
if resolved == "accept" {
|
||||
if err := st.trust.Grant(req.presetName); err != nil {
|
||||
st.flashError(fmt.Sprintf("trust grant: %v", err))
|
||||
} else {
|
||||
st.flashTransient(fmt.Sprintf("trusted preset %q (retry the call)", req.presetName))
|
||||
}
|
||||
} else {
|
||||
st.flashTransient(fmt.Sprintf("denied trust for preset %q", req.presetName))
|
||||
}
|
||||
st.drawStatusLine()
|
||||
// Discard the rest of the chunk; we intentionally don't
|
||||
// recurse into the regular handler so a stray Enter doesn't
|
||||
// submit anything to the focused PTY.
|
||||
_ = consumed
|
||||
return
|
||||
}
|
||||
st.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
forward := make([]byte, 0, len(chunk))
|
||||
flushForward := func() {
|
||||
if len(forward) == 0 {
|
||||
@@ -641,7 +742,7 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
}
|
||||
l := st.layoutSnapshot()
|
||||
st.launcher.SetSize(l.childCols(), l.childRows())
|
||||
if _, err := st.launcher.LaunchProcess(action.preset, action.preset.Name); err != nil {
|
||||
if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil {
|
||||
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
||||
}
|
||||
|
||||
@@ -687,6 +788,16 @@ func (st *uiState) flashError(msg string) {
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// flashTransient is the softer cousin of flashError used for
|
||||
// trust-prompt resolutions. Same status-line surface; the prefix differs.
|
||||
func (st *uiState) flashTransient(msg string) {
|
||||
st.mu.Lock()
|
||||
st.attentionText = msg
|
||||
st.attentionAt = ""
|
||||
st.mu.Unlock()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// repaintFocused redraws the current focused child's screen snapshot.
|
||||
// Callers must NOT hold st.mu — repaintFocused takes it
|
||||
// briefly itself.
|
||||
|
||||
Reference in New Issue
Block a user