|
|
|
|
@@ -65,6 +65,15 @@ type toolHost struct {
|
|
|
|
|
timers *timerManager
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
defaultMCPContentBytes = 12_000
|
|
|
|
|
maxMCPContentBytes = 65_536
|
|
|
|
|
defaultMCPTailBytes = 8_000
|
|
|
|
|
defaultScratchpadReadBytes = 12_000
|
|
|
|
|
defaultSearchLineBytes = 2_000
|
|
|
|
|
maxSearchMatches = 50
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
|
|
|
|
|
h := &toolHost{
|
|
|
|
|
sess: sess,
|
|
|
|
|
@@ -353,8 +362,8 @@ func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStat
|
|
|
|
|
return st, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
|
|
|
|
|
caller := h.WhoAmI(callerID)
|
|
|
|
|
func (h *toolHost) GetProjectStatus(callerID string, includeTools bool) (mcp.ProjectStatus, error) {
|
|
|
|
|
caller := h.WhoAmI(callerID, includeTools)
|
|
|
|
|
processes := h.ListProcesses(callerID, "")
|
|
|
|
|
pads, _ := h.pads.List()
|
|
|
|
|
return mcp.ProjectStatus{
|
|
|
|
|
@@ -365,7 +374,8 @@ func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error)
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
|
|
|
|
|
func (h *toolHost) GetProcessOutput(callerID string, args mcp.ProcessOutputArgs) (mcp.ProcessOutput, error) {
|
|
|
|
|
processID, mode, sinceOffset := args.ProcessID, args.Mode, args.SinceOffset
|
|
|
|
|
c := h.sess.FindChild(processID)
|
|
|
|
|
if c == nil {
|
|
|
|
|
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
|
|
|
@@ -399,11 +409,12 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
|
|
|
|
|
if c.Kind == KindAgent {
|
|
|
|
|
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
|
|
|
|
|
}
|
|
|
|
|
out.Content = normalizeGridText(txt)
|
|
|
|
|
content := normalizeGridText(txt)
|
|
|
|
|
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextMiddle(content, capLimit(args.MaxBytes, defaultMCPContentBytes))
|
|
|
|
|
return out, nil
|
|
|
|
|
case "stream":
|
|
|
|
|
b, end := c.StreamRead(sinceOffset)
|
|
|
|
|
out.Content = string(stripANSIBytes(nil, b))
|
|
|
|
|
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capBytesTail(stripANSIBytes(nil, b), capLimit(args.MaxBytes, defaultMCPContentBytes))
|
|
|
|
|
out.NewOffset = end
|
|
|
|
|
return out, nil
|
|
|
|
|
default:
|
|
|
|
|
@@ -411,34 +422,46 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
|
|
|
|
|
c := h.sess.FindChild(processID)
|
|
|
|
|
func (h *toolHost) GetProcessRawOutput(callerID string, args mcp.RawOutputArgs) (mcp.RawOutput, error) {
|
|
|
|
|
c := h.sess.FindChild(args.ProcessID)
|
|
|
|
|
if c == nil {
|
|
|
|
|
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
|
|
|
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
|
|
|
|
|
}
|
|
|
|
|
b, end := c.StreamRead(sinceOffset)
|
|
|
|
|
b, end := c.StreamRead(args.SinceOffset)
|
|
|
|
|
content, contentBytes, truncated, truncatedBytes := capBytesTail(b, capLimit(args.MaxBytes, defaultMCPContentBytes))
|
|
|
|
|
return mcp.RawOutput{
|
|
|
|
|
Content: string(b),
|
|
|
|
|
NewOffset: end,
|
|
|
|
|
Status: string(c.Status()),
|
|
|
|
|
Content: content,
|
|
|
|
|
NewOffset: end,
|
|
|
|
|
Status: string(c.Status()),
|
|
|
|
|
ContentBytes: contentBytes,
|
|
|
|
|
Truncated: truncated,
|
|
|
|
|
TruncatedBytes: truncatedBytes,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
|
|
|
|
|
c := h.sess.FindChild(processID)
|
|
|
|
|
func (h *toolHost) SearchOutput(callerID string, args mcp.SearchOutputArgs) (mcp.SearchResult, error) {
|
|
|
|
|
c := h.sess.FindChild(args.ProcessID)
|
|
|
|
|
if c == nil {
|
|
|
|
|
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
|
|
|
|
|
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
|
|
|
|
|
}
|
|
|
|
|
re, err := regexp.Compile(pattern)
|
|
|
|
|
re, err := regexp.Compile(args.Pattern)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
|
|
|
|
|
}
|
|
|
|
|
b, _ := c.StreamRead(0)
|
|
|
|
|
if kind == "rendered" {
|
|
|
|
|
if args.Kind == "rendered" {
|
|
|
|
|
b = stripANSIBytes(nil, b)
|
|
|
|
|
}
|
|
|
|
|
text := string(b)
|
|
|
|
|
lines := strings.Split(text, "\n")
|
|
|
|
|
limit := args.Limit
|
|
|
|
|
if limit <= 0 {
|
|
|
|
|
limit = 10
|
|
|
|
|
}
|
|
|
|
|
if limit > maxSearchMatches {
|
|
|
|
|
limit = maxSearchMatches
|
|
|
|
|
}
|
|
|
|
|
lineLimit := capLimit(args.MaxBytes, defaultSearchLineBytes)
|
|
|
|
|
matches := make([]mcp.SearchMatch, 0, limit)
|
|
|
|
|
truncated := false
|
|
|
|
|
for i, line := range lines {
|
|
|
|
|
@@ -447,6 +470,8 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
|
|
|
|
|
truncated = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
line, _, lineTruncated, _ := capTextTail(line, lineLimit)
|
|
|
|
|
truncated = truncated || lineTruncated
|
|
|
|
|
matches = append(matches, mcp.SearchMatch{LineNo: i + 1, Text: line})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -588,6 +613,7 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
|
|
|
|
|
if err != nil {
|
|
|
|
|
return mcp.SendInputResult{}, err
|
|
|
|
|
}
|
|
|
|
|
tailSince := c.StreamOffset()
|
|
|
|
|
if err := c.InjectAsOrchestrator(payload); err != nil {
|
|
|
|
|
return mcp.SendInputResult{}, err
|
|
|
|
|
}
|
|
|
|
|
@@ -599,7 +625,12 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
|
|
|
|
|
}
|
|
|
|
|
if mode != "none" {
|
|
|
|
|
time.Sleep(time.Duration(args.WaitMS) * time.Millisecond)
|
|
|
|
|
tail, err := h.GetProcessOutput(callerID, args.ProcessID, mode, 0)
|
|
|
|
|
tail, err := h.GetProcessOutput(callerID, mcp.ProcessOutputArgs{
|
|
|
|
|
ProcessID: args.ProcessID,
|
|
|
|
|
Mode: mode,
|
|
|
|
|
SinceOffset: tailSince,
|
|
|
|
|
MaxBytes: capLimit(args.TailMaxBytes, defaultMCPTailBytes),
|
|
|
|
|
})
|
|
|
|
|
if err == nil {
|
|
|
|
|
res.Tail = &tail
|
|
|
|
|
}
|
|
|
|
|
@@ -813,8 +844,30 @@ func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
|
|
|
|
|
|
|
|
|
|
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) ScratchpadRead(args mcp.ScratchpadReadArgs) (mcp.ScratchpadReadResult, error) {
|
|
|
|
|
content, rev, err := h.pads.Read(args.Name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return mcp.ScratchpadReadResult{}, err
|
|
|
|
|
}
|
|
|
|
|
offset := args.Offset
|
|
|
|
|
if offset < 0 {
|
|
|
|
|
offset = 0
|
|
|
|
|
}
|
|
|
|
|
if offset > len(content) {
|
|
|
|
|
offset = len(content)
|
|
|
|
|
}
|
|
|
|
|
limited, contentBytes, truncated, truncatedBytes := capTextHead(content[offset:], capLimit(args.MaxBytes, defaultScratchpadReadBytes))
|
|
|
|
|
next := offset + contentBytes
|
|
|
|
|
return mcp.ScratchpadReadResult{
|
|
|
|
|
Content: limited,
|
|
|
|
|
Revision: rev,
|
|
|
|
|
Offset: offset,
|
|
|
|
|
NextOffset: next,
|
|
|
|
|
ContentBytes: contentBytes,
|
|
|
|
|
TotalBytes: len(content),
|
|
|
|
|
Truncated: truncated,
|
|
|
|
|
TruncatedBytes: truncatedBytes,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
|
|
|
|
|
@@ -841,7 +894,7 @@ func (h *toolHost) ScratchpadDelete(name string) error {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
|
|
|
|
|
func (h *toolHost) WhoAmI(callerID string, includeTools bool) mcp.WhoAmI {
|
|
|
|
|
w := mcp.WhoAmI{
|
|
|
|
|
ProcessID: callerID,
|
|
|
|
|
Role: h.CallerRole(callerID),
|
|
|
|
|
@@ -849,7 +902,9 @@ func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
|
|
|
|
|
Path: h.sess.projectDir,
|
|
|
|
|
Key: h.sess.projectKey,
|
|
|
|
|
},
|
|
|
|
|
AvailableTools: availableToolsForRole(h.CallerRole(callerID)),
|
|
|
|
|
}
|
|
|
|
|
if includeTools {
|
|
|
|
|
w.AvailableTools = availableToolsForRole(h.CallerRole(callerID))
|
|
|
|
|
}
|
|
|
|
|
if c := h.sess.FindChild(callerID); c != nil {
|
|
|
|
|
w.Name = c.DisplayName()
|
|
|
|
|
@@ -1043,6 +1098,51 @@ func normalizeGridText(s string) string {
|
|
|
|
|
return strings.Join(out, "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func capLimit(requested, def int) int {
|
|
|
|
|
if requested <= 0 {
|
|
|
|
|
requested = def
|
|
|
|
|
}
|
|
|
|
|
if requested > maxMCPContentBytes {
|
|
|
|
|
requested = maxMCPContentBytes
|
|
|
|
|
}
|
|
|
|
|
if requested < 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return requested
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func capBytesTail(b []byte, limit int) (string, int, bool, int) {
|
|
|
|
|
if limit <= 0 || len(b) <= limit {
|
|
|
|
|
return string(b), len(b), false, 0
|
|
|
|
|
}
|
|
|
|
|
dropped := len(b) - limit
|
|
|
|
|
return string(b[dropped:]), limit, true, dropped
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func capTextTail(s string, limit int) (string, int, bool, int) {
|
|
|
|
|
return capBytesTail([]byte(s), limit)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func capTextHead(s string, limit int) (string, int, bool, int) {
|
|
|
|
|
if limit <= 0 || len(s) <= limit {
|
|
|
|
|
return s, len(s), false, 0
|
|
|
|
|
}
|
|
|
|
|
return s[:limit], limit, true, len(s) - limit
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func capTextMiddle(s string, limit int) (string, int, bool, int) {
|
|
|
|
|
if limit <= 0 || len(s) <= limit {
|
|
|
|
|
return s, len(s), false, 0
|
|
|
|
|
}
|
|
|
|
|
const marker = "\n...[truncated]...\n"
|
|
|
|
|
if limit <= len(marker)+2 {
|
|
|
|
|
return s[len(s)-limit:], limit, true, len(s) - limit
|
|
|
|
|
}
|
|
|
|
|
head := (limit - len(marker)) / 2
|
|
|
|
|
tail := limit - len(marker) - head
|
|
|
|
|
return s[:head] + marker + s[len(s)-tail:], limit, true, len(s) - limit
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// stripANSIBytes is the byte-slice form of stripANSI. Skips the
|
|
|
|
|
// string conversion and the regex DFA — useful when the caller will
|
|
|
|
|
// itself walk the result line-by-line (SearchOutput) or feed it to a
|
|
|
|
|
|