Simplify session lifecycle and MCP cleanup

This commit is contained in:
2026-05-14 20:51:37 +01:00
parent 27361f79c4
commit cc4bf9e904
16 changed files with 439 additions and 255 deletions

View File

@@ -36,6 +36,10 @@ type trustPrompter interface {
promptTrust(processID, presetName, reason string)
}
type scratchpadSink interface {
scratchpadsChanged()
}
// toolHost adapts the running session + scratchpad store + trust store
// to the MCP ToolHost interface. SPEC §7 tools route through here.
type toolHost struct {
@@ -55,6 +59,7 @@ type toolHost struct {
attention attentionSink
focus focusSink
prompter trustPrompter
scratch scratchpadSink
timersMu sync.Mutex
nextTimer int
@@ -129,7 +134,7 @@ func (h *toolHost) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.Pro
}
}
if p == nil {
return mcp.ProcessInfo{}, mcp.Errorf("unknown_agent", "unknown agent preset %q", args.Agent)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindUnknownAgent, "unknown agent preset %q", args.Agent)
}
display := args.Name
if display == "" {
@@ -148,7 +153,7 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
args.Kind = "command"
}
if args.Kind != "command" && args.Kind != "terminal" {
return mcp.ProcessInfo{}, mcp.Errorf("invalid_kind", "spawn_process: kind must be 'command' or 'terminal'")
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindInvalidKind, "spawn_process: kind must be 'command' or 'terminal'")
}
env := h.mergeEnv(args.Env)
if args.Kind == "terminal" {
@@ -163,11 +168,11 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
if args.Preset != "" {
if !h.trust.IsTrusted(args.Preset) {
h.askForTrust(callerID, args.Preset, "spawn_process")
return mcp.ProcessInfo{}, mcp.Errorf("needs_trust", "command preset %q is not trusted in this project — patterm has surfaced a confirmation; retry after the user accepts", args.Preset)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNeedsTrust, "command preset %q is not trusted in this project — patterm has surfaced a confirmation; retry after the user accepts", args.Preset)
}
ps := h.commandPresetByName(args.Preset)
if ps == nil {
return mcp.ProcessInfo{}, mcp.Errorf("not_found", "command preset %q not found", args.Preset)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "command preset %q not found", args.Preset)
}
display := args.Name
if display == "" {
@@ -181,7 +186,7 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
return h.processInfoOf(c), nil
}
if len(args.Argv) == 0 {
return mcp.ProcessInfo{}, mcp.Errorf("invalid_args", "spawn_process: either preset or argv required")
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "spawn_process: either preset or argv required")
}
display := args.Name
if display == "" {
@@ -198,17 +203,17 @@ func (h *toolHost) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp
func (h *toolHost) StartProcess(callerID, processID string) (mcp.ProcessInfo, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessInfo{}, mcp.Errorf("not_found", "no such process %q", processID)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if c.Kind != KindCommand {
return mcp.ProcessInfo{}, mcp.Errorf("wrong_kind", "start_process: only command entries can be started post-creation (this is %s)", c.Kind)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindWrongKind, "start_process: only command entries can be started post-creation (this is %s)", c.Kind)
}
if c.IsLive() {
return h.processInfoOf(c), nil
}
if c.PresetRef != "" && !h.trust.IsTrusted(c.PresetRef) {
h.askForTrust(callerID, c.PresetRef, "start_process")
return mcp.ProcessInfo{}, mcp.Errorf("needs_trust", "command preset %q is not trusted in this project", c.PresetRef)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNeedsTrust, "command preset %q is not trusted in this project", c.PresetRef)
}
cols, rows := h.size()
if err := h.sess.Start(processID, cols, rows); err != nil {
@@ -221,14 +226,14 @@ func (h *toolHost) StartProcess(callerID, processID string) (mcp.ProcessInfo, er
func (h *toolHost) RestartProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessInfo{}, mcp.Errorf("not_found", "no such process %q", processID)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if c.Kind != KindCommand && !c.IsLive() {
return mcp.ProcessInfo{}, mcp.Errorf("wrong_kind", "restart_process: %s entries can only be restarted while live", c.Kind)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindWrongKind, "restart_process: %s entries can only be restarted while live", c.Kind)
}
if c.Kind == KindCommand && c.PresetRef != "" && !h.trust.IsTrusted(c.PresetRef) {
h.askForTrust(callerID, c.PresetRef, "restart_process")
return mcp.ProcessInfo{}, mcp.Errorf("needs_trust", "command preset %q is not trusted in this project", c.PresetRef)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNeedsTrust, "command preset %q is not trusted in this project", c.PresetRef)
}
cols, rows := h.size()
if err := h.sess.Restart(processID, sig, cols, rows); err != nil {
@@ -241,7 +246,7 @@ func (h *toolHost) RestartProcess(callerID, processID string, sig syscall.Signal
func (h *toolHost) StopProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessInfo{}, mcp.Errorf("not_found", "no such process %q", processID)
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if err := h.sess.Kill(processID, sig); err != nil {
return mcp.ProcessInfo{}, err
@@ -252,7 +257,7 @@ func (h *toolHost) StopProcess(callerID, processID string, sig syscall.Signal) (
func (h *toolHost) CloseProcess(callerID, processID string) error {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.Errorf("not_found", "no such process %q", processID)
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
_ = c // close removes by id; the lookup just validates existence.
return h.sess.Close(processID, syscall.SIGTERM)
@@ -261,10 +266,10 @@ func (h *toolHost) CloseProcess(callerID, processID string) error {
func (h *toolHost) RenameProcess(callerID, processID, name string) error {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.Errorf("not_found", "no such process %q", processID)
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if name == "" {
return mcp.Errorf("invalid_args", "rename_process: name required")
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "rename_process: name required")
}
c.SetName(name)
return nil
@@ -272,7 +277,7 @@ func (h *toolHost) RenameProcess(callerID, processID, name string) error {
func (h *toolHost) SelectProcess(callerID, processID string) error {
if h.sess.FindChild(processID) == nil {
return mcp.Errorf("not_found", "no such process %q", processID)
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if h.focus != nil {
h.focus.focusProcess(processID)
@@ -299,7 +304,7 @@ func (h *toolHost) ListProcesses(callerID, kindFilter string) []mcp.ProcessInfo
func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStatus, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessStatus{}, mcp.Errorf("not_found", "no such process %q", processID)
return mcp.ProcessStatus{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
info := h.processInfoOf(c)
st := mcp.ProcessStatus{ProcessInfo: info}
@@ -337,7 +342,7 @@ func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error)
func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessOutput{}, mcp.Errorf("not_found", "no such process %q", processID)
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
out := mcp.ProcessOutput{
Mode: mode,
@@ -376,14 +381,14 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
out.NewOffset = end
return out, nil
default:
return mcp.ProcessOutput{}, mcp.Errorf("invalid_args", "unknown mode %q (want grid|stream)", mode)
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown mode %q (want grid|stream)", mode)
}
}
func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.RawOutput{}, mcp.Errorf("not_found", "no such process %q", processID)
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
b, end := c.StreamRead(sinceOffset)
return mcp.RawOutput{
@@ -396,11 +401,11 @@ func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset i
func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
c := h.sess.FindChild(processID)
if c == nil {
return mcp.SearchResult{}, mcp.Errorf("not_found", "no such process %q", processID)
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
re, err := regexp.Compile(pattern)
if err != nil {
return mcp.SearchResult{}, mcp.Errorf("invalid_args", "regex: %v", err)
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
b, _ := c.StreamRead(0)
text := string(b)
@@ -425,11 +430,11 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (bool, string, error) {
c := h.sess.FindChild(processID)
if c == nil {
return false, "", mcp.Errorf("not_found", "no such process %q", processID)
return false, "", mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
re, err := regexp.Compile(pattern)
if err != nil {
return false, "", mcp.Errorf("invalid_args", "regex: %v", err)
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
if scope == "" {
scope = "grid"
@@ -450,7 +455,7 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
b, _ := c.StreamRead(0)
text = stripANSI(string(b))
default:
return false, "", mcp.Errorf("invalid_args", "unknown scope %q (want grid|scrollback)", scope)
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
}
if m := re.FindString(text); m != "" {
return true, m, nil
@@ -468,7 +473,7 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
if c == nil {
return nil, mcp.Errorf("not_found", "no such process %q", processID)
return nil, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
src := c.Ports()
out := make([]mcp.PortSighting, 0, len(src))
@@ -485,7 +490,7 @@ func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighti
func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendInputResult, error) {
c := h.sess.FindChild(args.ProcessID)
if c == nil {
return mcp.SendInputResult{}, mcp.Errorf("not_found", "no such process %q", args.ProcessID)
return mcp.SendInputResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
if !c.IsLive() {
return mcp.SendInputResult{}, fmt.Errorf("process %q is %s", args.ProcessID, c.Status())
@@ -539,7 +544,7 @@ func encodeInput(args mcp.SendInputArgs) ([]byte, error) {
case "key":
return encodeKey(args.Key)
}
return nil, mcp.Errorf("invalid_args", "send_input: unknown kind %q", args.Kind)
return nil, mcp.Errorf(mcp.ErrorKindInvalidArgs, "send_input: unknown kind %q", args.Kind)
}
// encodeKey maps a SPEC §7 named key to bytes. We use legacy xterm
@@ -601,7 +606,7 @@ func encodeKey(key string) ([]byte, error) {
case "f12":
return []byte("\x1b[24~"), nil
}
return nil, mcp.Errorf("invalid_args", "unknown key %q", key)
return nil, mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown key %q", key)
}
// ───────────────────────────────────────────────────────────────────
@@ -616,7 +621,7 @@ func encodeKey(key string) ([]byte, error) {
func (h *toolHost) SendMessage(callerID, targetID, message string) error {
target := h.sess.FindChild(targetID)
if target == nil {
return mcp.Errorf("not_found", "no such process %q", targetID)
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", targetID)
}
caller := h.sess.FindChild(callerID)
line, err := classifySendMessage(caller, target, callerID, message)
@@ -637,7 +642,7 @@ func (h *toolHost) SendMessage(callerID, targetID, message string) error {
// top-level process.
func classifySendMessage(caller, target *Child, callerID, message string) (string, error) {
if target.ID == callerID {
return "", mcp.Errorf("not_related", "send_message: cannot send to self")
return "", mcp.Errorf(mcp.ErrorKindNotRelated, "send_message: cannot send to self")
}
if caller != nil && target.ParentID == caller.ID {
return "[orchestrator] " + message + "\r", nil
@@ -648,7 +653,7 @@ func classifySendMessage(caller, target *Child, callerID, message string) (strin
if caller == nil && target.ParentID == "" {
return "[orchestrator] " + message + "\r", nil
}
return "", mcp.Errorf("not_related", "send_message: %q is neither parent nor child of caller (siblings must route through the parent in v1)", target.ID)
return "", mcp.Errorf(mcp.ErrorKindNotRelated, "send_message: %q is neither parent nor child of caller (siblings must route through the parent in v1)", target.ID)
}
func (h *toolHost) RequestHumanAttention(callerID, processID, reason string) error {
@@ -661,7 +666,7 @@ func (h *toolHost) RequestHumanAttention(callerID, processID, reason string) err
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
caller := h.sess.FindChild(callerID)
if caller == nil {
return "", mcp.Errorf("not_found", "caller %q not known to patterm", callerID)
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", callerID)
}
h.timersMu.Lock()
h.nextTimer++
@@ -685,7 +690,27 @@ func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (st
// Scratchpads / Meta
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) Scratchpads() *scratchpad.Store { return h.pads }
func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadRead(name string) (string, string, error) {
return h.pads.Read(name)
}
func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
rev, err := h.pads.Write(name, content, expectedRevision)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return rev, err
}
func (h *toolHost) ScratchpadAppend(name, content string) error {
err := h.pads.Append(name, content)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return err
}
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
w := mcp.WhoAmI{