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:
@@ -9,42 +9,224 @@ import (
|
||||
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||
)
|
||||
|
||||
// ToolHost is the interface the in-process server uses to reach the
|
||||
// running patterm process's state. The app package implements this so
|
||||
// internal/mcp doesn't import internal/app (which would be a cycle).
|
||||
type ToolHost interface {
|
||||
Children() []ChildInfo
|
||||
Spawn(callerID, name string, argv []string, shell bool) (ChildInfo, error)
|
||||
SpawnAgent(callerID, presetName, displayName, initialPrompt string) (ChildInfo, error)
|
||||
ReadOutput(callerID, childID, mode string, sinceOffset int) (content string, newOffset int, err error)
|
||||
SendInput(callerID, childID string, payload []byte, appendNewline bool) error
|
||||
Kill(callerID, childID string, sig syscall.Signal) error
|
||||
SendMessageTo(callerID, targetID, message string) error
|
||||
ReportToParent(callerID, message string) error
|
||||
TimerWait(callerID string, seconds float64, label string) (string, error)
|
||||
WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (matched bool, snippet string, err error)
|
||||
RequestHumanAttention(callerID, childID, reason string) error
|
||||
Scratchpads() *scratchpad.Store
|
||||
// JSON-RPC error codes used by the patterm MCP surface. -32700..-32600
|
||||
// are the standard parse/invalid-request codes; the SPEC-defined error
|
||||
// names live in the -32000 range with a structured `data.kind` so the
|
||||
// caller can branch on the error type rather than parsing strings.
|
||||
const (
|
||||
codeParseError = -32700
|
||||
codeInvalidRequest = -32600
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
codeInternal = -32000
|
||||
codeNeedsTrust = -32010
|
||||
codeRoleForbidden = -32011
|
||||
codeNotRelated = -32012
|
||||
codeNotFound = -32013
|
||||
codeWrongKind = -32014
|
||||
codeUnknownAgent = -32015
|
||||
)
|
||||
|
||||
// ResolveCallerIdentity translates a per-spawn identity token into
|
||||
// the child ID the server stores in its connection state.
|
||||
// CallerRole is one of "orchestrator" or "sub-agent". SPEC §7 caller
|
||||
// role: orchestrators sit at the root of a session tree; sub-agents
|
||||
// were spawned by another agent.
|
||||
type CallerRole string
|
||||
|
||||
const (
|
||||
RoleOrchestrator CallerRole = "orchestrator"
|
||||
RoleSubAgent CallerRole = "sub-agent"
|
||||
)
|
||||
|
||||
// ToolHost is the interface the in-process server uses to reach the
|
||||
// running patterm process's state. internal/app implements this; the
|
||||
// split keeps internal/mcp free of an internal/app import (which would
|
||||
// be cyclic).
|
||||
type ToolHost interface {
|
||||
// Identity resolution. The mcp-stdio greeting carries a per-spawn
|
||||
// token; the server resolves it to a process_id before dispatching
|
||||
// the rest of the connection's calls.
|
||||
ResolveCallerIdentity(identity string) string
|
||||
|
||||
// PolicyCheck — SPEC §9. Returns "allow" / "punt" / "unknown" for
|
||||
// a candidate auto-answer prompt the orchestrator is reading.
|
||||
PolicyCheck(prompt string) string
|
||||
// CallerRole returns the role for the given process_id. Unknown
|
||||
// callers default to RoleOrchestrator (treated as a top-level peer)
|
||||
// so they don't get silently denied.
|
||||
CallerRole(processID string) CallerRole
|
||||
|
||||
// Lifecycle (SPEC §7).
|
||||
SpawnAgent(callerID string, args SpawnAgentArgs) (ProcessInfo, error)
|
||||
SpawnProcess(callerID string, args SpawnProcessArgs) (ProcessInfo, error)
|
||||
StartProcess(callerID, processID string) (ProcessInfo, error)
|
||||
RestartProcess(callerID, processID string, signal syscall.Signal) (ProcessInfo, error)
|
||||
StopProcess(callerID, processID string, signal syscall.Signal) (ProcessInfo, error)
|
||||
CloseProcess(callerID, processID string) error
|
||||
RenameProcess(callerID, processID, name string) error
|
||||
SelectProcess(callerID, processID string) error
|
||||
|
||||
// Inspection.
|
||||
ListProcesses(callerID, kindFilter string) []ProcessInfo
|
||||
GetProcessStatus(callerID, processID string) (ProcessStatus, error)
|
||||
GetProjectStatus(callerID string) (ProjectStatus, error)
|
||||
GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (ProcessOutput, error)
|
||||
GetProcessRawOutput(callerID, processID string, sinceOffset int64) (RawOutput, error)
|
||||
SearchOutput(callerID, processID, pattern, kind string, limit int) (SearchResult, error)
|
||||
WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error)
|
||||
GetProcessPorts(callerID, processID string) ([]PortSighting, error)
|
||||
|
||||
// I/O.
|
||||
SendInput(callerID string, args SendInputArgs) (SendInputResult, error)
|
||||
|
||||
// Coordination.
|
||||
SendMessage(callerID, targetID, message string) error
|
||||
RequestHumanAttention(callerID, processID, reason string) error
|
||||
TimerWait(callerID string, seconds float64, label string) (string, error)
|
||||
|
||||
// Scratchpads.
|
||||
Scratchpads() *scratchpad.Store
|
||||
|
||||
// Meta.
|
||||
WhoAmI(callerID string) WhoAmI
|
||||
Help(callerID, topic string) HelpResponse
|
||||
}
|
||||
|
||||
// ChildInfo is what list_children / spawn_process / spawn_agent return.
|
||||
// Matches SPEC §7 shape plus the §11 idle exposure.
|
||||
type ChildInfo struct {
|
||||
ID string `json:"child_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
// ProcessInfo is the entry shape returned by list_processes, spawn_*,
|
||||
// stop_process, restart_process, start_process. SPEC §7.
|
||||
type ProcessInfo struct {
|
||||
ID string `json:"process_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
ParentProcessID string `json:"parent_process_id,omitempty"`
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
Trusted *bool `json:"trusted,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessStatus is what get_process_status returns. Richer than
|
||||
// ProcessInfo: includes pane geometry, cursor, and active screen.
|
||||
type ProcessStatus struct {
|
||||
ProcessInfo
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
Argv []string `json:"argv,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
ActiveScreen string `json:"active_screen,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Cursor Cursor `json:"cursor"`
|
||||
ScreenVersion int64 `json:"screen_version,omitempty"`
|
||||
}
|
||||
|
||||
// Cursor matches SPEC §7's `{x, y}` payload.
|
||||
type Cursor struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
// ProjectStatus is what get_project_status returns — everything an
|
||||
// agent needs to orient itself in one call.
|
||||
type ProjectStatus struct {
|
||||
Project ProjectMeta `json:"project"`
|
||||
Caller WhoAmI `json:"caller"`
|
||||
Processes []ProcessInfo `json:"processes"`
|
||||
Scratchpads []scratchpad.Entry `json:"scratchpads"`
|
||||
}
|
||||
|
||||
// ProjectMeta is the project root info echoed in many payloads.
|
||||
type ProjectMeta struct {
|
||||
Path string `json:"path"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// ProcessOutput is the get_process_output payload. SPEC §7 enriches
|
||||
// the old read_output result with screen geometry + version.
|
||||
type ProcessOutput struct {
|
||||
Content string `json:"content"`
|
||||
Mode string `json:"mode"`
|
||||
NewOffset int64 `json:"new_offset,omitempty"`
|
||||
ActiveScreen string `json:"active_screen,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Cursor Cursor `json:"cursor"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ScreenVersion int64 `json:"screen_version,omitempty"`
|
||||
}
|
||||
|
||||
// RawOutput is the get_process_raw_output payload — ANSI preserved.
|
||||
type RawOutput struct {
|
||||
Content string `json:"content"`
|
||||
NewOffset int64 `json:"new_offset"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResult is search_output's payload.
|
||||
type SearchResult struct {
|
||||
Matches []SearchMatch `json:"matches"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type SearchMatch struct {
|
||||
LineNo int `json:"line_no"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// PortSighting matches the per-child store in internal/app.
|
||||
type PortSighting struct {
|
||||
Port int `json:"port"`
|
||||
URL string `json:"url,omitempty"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
}
|
||||
|
||||
// SpawnAgentArgs is the input shape for spawn_agent.
|
||||
type SpawnAgentArgs struct {
|
||||
Agent string `json:"agent"`
|
||||
AgentInstructions string `json:"agent_instructions"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SpawnProcessArgs is the input shape for spawn_process.
|
||||
type SpawnProcessArgs struct {
|
||||
Kind string `json:"kind"` // "terminal" | "command"
|
||||
Preset string `json:"preset"`
|
||||
Argv []string `json:"argv"`
|
||||
Name string `json:"name"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
Env map[string]string `json:"env"`
|
||||
Shell bool `json:"shell"`
|
||||
}
|
||||
|
||||
// SendInputArgs is the input shape for send_input — covers text /
|
||||
// paste / key with the optional wait+tail tail-after-send.
|
||||
type SendInputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Kind string `json:"kind"` // "text" | "paste" | "key"
|
||||
Text string `json:"text"`
|
||||
Key string `json:"key"`
|
||||
Submit *bool `json:"submit"`
|
||||
WaitMS int `json:"wait_ms"`
|
||||
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
|
||||
}
|
||||
|
||||
// SendInputResult is the return shape of send_input.
|
||||
type SendInputResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Tail *ProcessOutput `json:"tail,omitempty"`
|
||||
}
|
||||
|
||||
// WhoAmI is the whoami return shape.
|
||||
type WhoAmI struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Name string `json:"name"`
|
||||
Role CallerRole `json:"role"`
|
||||
ParentProcessID string `json:"parent_process_id,omitempty"`
|
||||
Project ProjectMeta `json:"project"`
|
||||
AvailableTools []string `json:"available_tools"`
|
||||
}
|
||||
|
||||
// HelpResponse is the help return shape.
|
||||
type HelpResponse struct {
|
||||
Topic string `json:"topic"`
|
||||
Content string `json:"content"`
|
||||
RelatedTools []string `json:"related_tools,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) SetHost(h ToolHost) {
|
||||
@@ -54,7 +236,7 @@ func (s *Server) SetHost(h ToolHost) {
|
||||
}
|
||||
|
||||
// dispatch routes a single JSON-RPC request. callerID is the ID of the
|
||||
// child that owns this connection (resolved at greeting time).
|
||||
// process that owns this connection (resolved at greeting time).
|
||||
func (s *Server) dispatch(callerID string, req []byte) []byte {
|
||||
var msg struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
@@ -63,152 +245,271 @@ func (s *Server) dispatch(callerID string, req []byte) []byte {
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(req, &msg); err != nil {
|
||||
return jsonRPCError(nil, -32700, "parse error: "+err.Error())
|
||||
return jsonRPCError(nil, codeParseError, "parse error: "+err.Error(), nil)
|
||||
}
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host == nil {
|
||||
return jsonRPCError(msg.ID, -32000, "patterm: tool host not initialized")
|
||||
return jsonRPCError(msg.ID, codeInternal, "patterm: tool host not initialized", nil)
|
||||
}
|
||||
|
||||
result, code, errMsg := callTool(host, callerID, msg.Method, msg.Params)
|
||||
result, code, errMsg, data := callTool(host, callerID, msg.Method, msg.Params)
|
||||
if errMsg != "" {
|
||||
return jsonRPCError(msg.ID, code, errMsg)
|
||||
return jsonRPCError(msg.ID, code, errMsg, data)
|
||||
}
|
||||
return jsonRPCResult(msg.ID, result)
|
||||
}
|
||||
|
||||
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string) {
|
||||
// toolError lets host implementations attach a structured `kind` to a
|
||||
// JSON-RPC error so callers can branch on error type without parsing
|
||||
// the message. The MCP layer maps recognized error kinds to their SPEC
|
||||
// error codes; unknown errors fall through to codeInternal.
|
||||
type toolError struct {
|
||||
Kind string `json:"kind"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *toolError) Error() string { return e.Message }
|
||||
|
||||
// Errorf is a convenience for host implementations to return typed
|
||||
// errors that map to JSON-RPC codes (needs_trust, role_forbidden, …).
|
||||
func Errorf(kind, format string, a ...any) error {
|
||||
return &toolError{Kind: kind, Message: fmt.Sprintf(format, a...)}
|
||||
}
|
||||
|
||||
// callTool is the central dispatch. Returns (result, code, errMsg,
|
||||
// errData). On success errMsg is empty.
|
||||
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string, any) {
|
||||
switch method {
|
||||
case "list_children":
|
||||
return h.Children(), 0, ""
|
||||
case "spawn_agent":
|
||||
var p SpawnAgentArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Agent == "" {
|
||||
return nil, codeInvalidParams, "spawn_agent: agent required", nil
|
||||
}
|
||||
// Role gate: only orchestrators may call spawn_agent (SPEC §8).
|
||||
if h.CallerRole(callerID) == RoleSubAgent {
|
||||
return nil, codeRoleForbidden, "spawn_agent: sub-agents cannot spawn agents; use vendor-native subagent tooling or ask your parent", structuredKind("role_forbidden")
|
||||
}
|
||||
info, err := h.SpawnAgent(callerID, p)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "spawn_process":
|
||||
var p SpawnProcessArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
info, err := h.SpawnProcess(callerID, p)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "start_process":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
info, err := h.StartProcess(callerID, p.ProcessID)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "restart_process":
|
||||
var p struct {
|
||||
Preset string `json:"preset"`
|
||||
Argv []string `json:"argv"`
|
||||
Shell bool `json:"shell"`
|
||||
Name string `json:"name"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
ProcessID string `json:"process_id"`
|
||||
Signal int `json:"signal"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
// Preset-by-name is the preferred path per SPEC §7; argv is the
|
||||
// escape hatch. We don't load process presets here — the host
|
||||
// is the source of truth — so a named preset call is rejected
|
||||
// unless the caller also supplied argv. (Wiring full preset
|
||||
// resolution into MCP is a small follow-up; the host's palette
|
||||
// path covers the named case today.)
|
||||
if len(p.Argv) == 0 {
|
||||
return nil, -32602, "spawn_process: argv required"
|
||||
}
|
||||
ci, err := h.Spawn(callerID, p.Name, p.Argv, p.Shell)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return ci, 0, ""
|
||||
sig := syscall.Signal(p.Signal)
|
||||
info, err := h.RestartProcess(callerID, p.ProcessID, sig)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "spawn_agent":
|
||||
case "stop_process":
|
||||
var p struct {
|
||||
Preset string `json:"preset"`
|
||||
InitialPrompt string `json:"initial_prompt"`
|
||||
Name string `json:"name"`
|
||||
ProcessID string `json:"process_id"`
|
||||
Signal int `json:"signal"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Preset == "" {
|
||||
return nil, -32602, "spawn_agent: preset required"
|
||||
}
|
||||
ci, err := h.SpawnAgent(callerID, p.Preset, p.Name, p.InitialPrompt)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return ci, 0, ""
|
||||
sig := syscall.Signal(p.Signal)
|
||||
info, err := h.StopProcess(callerID, p.ProcessID, sig)
|
||||
return mapToolResult(info, err)
|
||||
|
||||
case "read_output":
|
||||
case "close_process":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.CloseProcess(callerID, p.ProcessID); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "rename_process":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
ProcessID string `json:"process_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.RenameProcess(callerID, p.ProcessID, p.Name); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "select_process":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.SelectProcess(callerID, p.ProcessID); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "list_processes":
|
||||
var p struct{ Kind string `json:"kind"` }
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
return h.ListProcesses(callerID, p.Kind), 0, "", nil
|
||||
|
||||
case "get_process_status":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
st, err := h.GetProcessStatus(callerID, p.ProcessID)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return st, 0, "", nil
|
||||
|
||||
case "get_project_status":
|
||||
ps, err := h.GetProjectStatus(callerID)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return ps, 0, "", nil
|
||||
|
||||
case "get_process_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Mode string `json:"mode"`
|
||||
SinceOffset int `json:"since_offset"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Mode == "" {
|
||||
p.Mode = "grid"
|
||||
}
|
||||
content, newOff, err := h.ReadOutput(callerID, p.ChildID, p.Mode, p.SinceOffset)
|
||||
out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return mapToolError(err)
|
||||
}
|
||||
return map[string]any{
|
||||
"content": content,
|
||||
"new_offset": newOff,
|
||||
"mode": p.Mode,
|
||||
}, 0, ""
|
||||
return out, 0, "", nil
|
||||
|
||||
case "get_process_raw_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
out, err := h.GetProcessRawOutput(callerID, p.ProcessID, p.SinceOffset)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return out, 0, "", nil
|
||||
|
||||
case "search_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
Kind string `json:"kind"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Limit <= 0 {
|
||||
p.Limit = 20
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = "rendered"
|
||||
}
|
||||
res, err := h.SearchOutput(callerID, p.ProcessID, p.Pattern, p.Kind, p.Limit)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return res, 0, "", nil
|
||||
|
||||
case "wait_for_pattern":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
TimeoutSeconds float64 `json:"timeout_seconds"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
matched, snippet, err := h.WaitForPattern(callerID, p.ProcessID, p.Pattern, p.TimeoutSeconds, p.Scope)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return map[string]any{"matched": matched, "snippet": snippet}, 0, "", nil
|
||||
|
||||
case "get_process_ports":
|
||||
var p struct{ ProcessID string `json:"process_id"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
ports, err := h.GetProcessPorts(callerID, p.ProcessID)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return map[string]any{"ports": ports}, 0, "", nil
|
||||
|
||||
case "send_input":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Input string `json:"input"`
|
||||
AppendNewline *bool `json:"append_newline"`
|
||||
}
|
||||
var p SendInputArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
appendNL := true
|
||||
if p.AppendNewline != nil {
|
||||
appendNL = *p.AppendNewline
|
||||
res, err := h.SendInput(callerID, p)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
if err := h.SendInput(callerID, p.ChildID, []byte(p.Input), appendNL); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return res, 0, "", nil
|
||||
|
||||
case "kill":
|
||||
case "send_message":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Signal int `json:"signal"`
|
||||
TargetProcessID string `json:"target_process_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
sig := syscall.Signal(p.Signal)
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
if err := h.SendMessage(callerID, p.TargetProcessID, p.Message); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
if err := h.Kill(callerID, p.ChildID, sig); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "send_message_to":
|
||||
case "request_human_attention":
|
||||
var p struct {
|
||||
Target string `json:"target"`
|
||||
Message string `json:"message"`
|
||||
ProcessID string `json:"process_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.SendMessageTo(callerID, p.Target, p.Message); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
if err := h.RequestHumanAttention(callerID, p.ProcessID, p.Reason); err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return "ok", 0, ""
|
||||
|
||||
case "report_to_parent":
|
||||
var p struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if err := h.ReportToParent(callerID, p.Message); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return "ok", 0, "", nil
|
||||
|
||||
case "timer_wait":
|
||||
var p struct {
|
||||
@@ -216,61 +517,31 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
Label string `json:"label"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
id, err := h.TimerWait(callerID, p.Seconds, p.Label)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return mapToolError(err)
|
||||
}
|
||||
return map[string]string{"timer_id": id}, 0, ""
|
||||
|
||||
case "wait_for_pattern":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
TimeoutSeconds float64 `json:"timeout_seconds"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
matched, snippet, err := h.WaitForPattern(callerID, p.ChildID, p.Pattern, p.TimeoutSeconds)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return map[string]any{"matched": matched, "snippet": snippet}, 0, ""
|
||||
|
||||
case "request_human_attention":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if err := h.RequestHumanAttention(callerID, p.ChildID, p.Reason); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return map[string]string{"timer_id": id}, 0, "", nil
|
||||
|
||||
case "scratchpad_list":
|
||||
entries, err := h.Scratchpads().List()
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return entries, 0, ""
|
||||
return entries, 0, "", nil
|
||||
|
||||
case "scratchpad_read":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var p struct{ Name string `json:"name"` }
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
content, rev, err := h.Scratchpads().Read(p.Name)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return map[string]any{"content": content, "revision": rev}, 0, ""
|
||||
return map[string]any{"content": content, "revision": rev}, 0, "", nil
|
||||
|
||||
case "scratchpad_write":
|
||||
var p struct {
|
||||
@@ -279,22 +550,19 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
ExpectedRevision string `json:"expected_revision"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
rev, err := h.Scratchpads().Write(p.Name, p.Content, p.ExpectedRevision)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
// Optimistic-concurrency miss returns ok:false + current_revision
|
||||
// rather than a JSON-RPC error so callers can re-read + merge.
|
||||
var rme *scratchpad.RevisionMismatchError
|
||||
if errors.As(err, &rme) {
|
||||
return map[string]any{"ok": false, "current_revision": rme.CurrentRevision}, 0, "", nil
|
||||
}
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return map[string]any{"revision": rev}, 0, ""
|
||||
|
||||
case "policy_check":
|
||||
var p struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
return map[string]string{"decision": h.PolicyCheck(p.Prompt)}, 0, ""
|
||||
return map[string]any{"ok": true, "revision": rev}, 0, "", nil
|
||||
|
||||
case "scratchpad_append":
|
||||
var p struct {
|
||||
@@ -302,14 +570,62 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if err := h.Scratchpads().Append(p.Name, p.Content); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return "ok", 0, ""
|
||||
return map[string]any{"ok": true}, 0, "", nil
|
||||
|
||||
case "whoami":
|
||||
return h.WhoAmI(callerID), 0, "", nil
|
||||
|
||||
case "help":
|
||||
var p struct{ Topic string `json:"topic"` }
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
return h.Help(callerID, p.Topic), 0, "", nil
|
||||
}
|
||||
return nil, -32601, "method not found: " + method
|
||||
return nil, codeMethodNotFound, "method not found: " + method, nil
|
||||
}
|
||||
|
||||
// mapToolResult is the (info, err) → JSON-RPC reply helper for the
|
||||
// many handlers that return a ProcessInfo-ish struct.
|
||||
func mapToolResult(v any, err error) (any, int, string, any) {
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return v, 0, "", nil
|
||||
}
|
||||
|
||||
// mapToolError translates a host error to (code, message, data). If the
|
||||
// error is a *toolError its Kind names the SPEC-defined category and we
|
||||
// pick a stable JSON-RPC code for that category; otherwise we return a
|
||||
// generic internal error.
|
||||
func mapToolError(err error) (any, int, string, any) {
|
||||
var te *toolError
|
||||
if errors.As(err, &te) {
|
||||
code := codeInternal
|
||||
switch te.Kind {
|
||||
case "needs_trust":
|
||||
code = codeNeedsTrust
|
||||
case "role_forbidden":
|
||||
code = codeRoleForbidden
|
||||
case "not_related":
|
||||
code = codeNotRelated
|
||||
case "not_found":
|
||||
code = codeNotFound
|
||||
case "wrong_kind":
|
||||
code = codeWrongKind
|
||||
case "unknown_agent":
|
||||
code = codeUnknownAgent
|
||||
}
|
||||
return nil, code, te.Message, structuredKind(te.Kind)
|
||||
}
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
|
||||
func structuredKind(kind string) map[string]string {
|
||||
return map[string]string{"kind": kind}
|
||||
}
|
||||
|
||||
func unmarshalParams(raw json.RawMessage, out any) error {
|
||||
@@ -319,6 +635,13 @@ func unmarshalParams(raw json.RawMessage, out any) error {
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func unmarshalParamsOptional(raw json.RawMessage, out any) error {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func jsonRPCResult(id json.RawMessage, result any) []byte {
|
||||
resp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
@@ -329,19 +652,19 @@ func jsonRPCResult(id json.RawMessage, result any) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
func jsonRPCError(id json.RawMessage, code int, message string) []byte {
|
||||
func jsonRPCError(id json.RawMessage, code int, message string, data any) []byte {
|
||||
errBody := map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
if data != nil {
|
||||
errBody["data"] = data
|
||||
}
|
||||
resp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
"error": errBody,
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
return b
|
||||
}
|
||||
|
||||
// Compile-time guard: every dispatch path is covered. fmt is imported
|
||||
// only so future error wrapping can land without re-adding the import.
|
||||
var _ = fmt.Sprintf
|
||||
|
||||
Reference in New Issue
Block a user