Compare commits
3 Commits
d2342f99cf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 45263d59f8 | |||
| 51aac9f447 | |||
| da46340a82 |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -13,9 +13,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
### Changed
|
||||
- The tab bar now shows each visible agent tab's own summary instead
|
||||
of only rendering the focused tab's summary.
|
||||
- Grid-mode `get_process_output` now returns whitespace-normalized
|
||||
text to avoid sending padded terminal rows and repeated blank lines
|
||||
over MCP.
|
||||
- `get_process_output` now returns aggressively canonical terminal text
|
||||
by default, removing ANSI/control noise, decorative borders, duplicate
|
||||
status churn, and volatile progress/timer fragments; raw PTY bytes are
|
||||
opt-in with `raw:true`.
|
||||
- MCP responses now use slimmer defaults: tool-call JSON is no longer
|
||||
duplicated into text content, large output and scratchpad reads are
|
||||
capped with truncation metadata, and `whoami` / `get_project_status`
|
||||
only include full tool lists when `include_tools` is requested.
|
||||
|
||||
### Fixed
|
||||
- Injected agent input now sends the submit Enter as a separated,
|
||||
|
||||
1
TODO.md
1
TODO.md
@@ -0,0 +1 @@
|
||||
- [ ] Pasting into codex is no longer clean, it sends loads of messages rather than one clean paste.
|
||||
|
||||
143
internal/app/canonical.go
Normal file
143
internal/app/canonical.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
statusVolatileRE = regexp.MustCompile(`\b(?:\d+h\s*)?\d+m\s*\d+s\b|\b\d{1,2}:\d{2}(?::\d{2})?\b|\b\d+(?:\.\d+)?s\b`)
|
||||
counterRE = regexp.MustCompile(`\b\d+\s*/\s*\d+\b|\b\d{1,3}%`)
|
||||
spinnerGlyphRE = regexp.MustCompile(`^[\s⠁⠂⠄⡀⢀⠠⠐⠈⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏•·∙◐◓◑◒]+`)
|
||||
)
|
||||
|
||||
func canonicalizeTerminalText(s string, maxLines int) (string, bool, int) {
|
||||
s = string(stripANSIBytes(nil, []byte(s)))
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = carriageReturnToLines(s)
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
pendingBlank := false
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimRightFunc(stripControlRunes(raw), unicode.IsSpace)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(out) > 0 {
|
||||
pendingBlank = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isBorderOnlyLine(line) {
|
||||
continue
|
||||
}
|
||||
line = canonicalStatusLine(line)
|
||||
if len(out) > 0 && out[len(out)-1] == line {
|
||||
pendingBlank = false
|
||||
continue
|
||||
}
|
||||
if pendingBlank {
|
||||
out = append(out, "")
|
||||
pendingBlank = false
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
|
||||
if maxLines > 0 && len(out) > maxLines {
|
||||
dropped := strings.Join(out[:len(out)-maxLines], "\n")
|
||||
out = out[len(out)-maxLines:]
|
||||
return strings.Join(out, "\n"), true, len(dropped)
|
||||
}
|
||||
return strings.Join(out, "\n"), false, 0
|
||||
}
|
||||
|
||||
func carriageReturnToLines(s string) string {
|
||||
var out []string
|
||||
var current strings.Builder
|
||||
flush := func() {
|
||||
out = append(out, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
for len(s) > 0 {
|
||||
r, size := utf8.DecodeRuneInString(s)
|
||||
s = s[size:]
|
||||
switch r {
|
||||
case '\r':
|
||||
current.Reset()
|
||||
case '\n':
|
||||
flush()
|
||||
default:
|
||||
current.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 || len(out) == 0 {
|
||||
flush()
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func stripControlRunes(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\t' || r == '\n' {
|
||||
return r
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
func isBorderOnlyLine(s string) bool {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
seenBox := false
|
||||
for _, r := range trimmed {
|
||||
if r >= 0x2500 && r <= 0x257f {
|
||||
seenBox = true
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case ' ', '\t', '-', '_', '=', '+', '|', ':', '.', '\'', '"', '`', '*':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return seenBox
|
||||
}
|
||||
|
||||
func canonicalStatusLine(s string) string {
|
||||
if !looksStatusLike(s) {
|
||||
return s
|
||||
}
|
||||
leading := len(s) - len(strings.TrimLeftFunc(s, unicode.IsSpace))
|
||||
prefix := s[:leading]
|
||||
body := s[leading:]
|
||||
body = spinnerGlyphRE.ReplaceAllString(body, "")
|
||||
body = statusVolatileRE.ReplaceAllString(body, "[time]")
|
||||
body = counterRE.ReplaceAllString(body, "[count]")
|
||||
return prefix + strings.TrimRightFunc(body, unicode.IsSpace)
|
||||
}
|
||||
|
||||
func looksStatusLike(s string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, token := range []string{
|
||||
"status", "running", "remaining", "progress", "loading",
|
||||
"building", "installing", "downloading", "waiting", "working",
|
||||
} {
|
||||
if strings.Contains(lower, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
r, _ := utf8.DecodeRuneInString(trimmed)
|
||||
return strings.ContainsRune("⠁⠂⠄⡀⢀⠠⠐⠈⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏•·∙◐◓◑◒", r)
|
||||
}
|
||||
167
internal/app/canonical_test.go
Normal file
167
internal/app/canonical_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/mcp"
|
||||
"github.com/hjbdev/patterm/internal/preset"
|
||||
)
|
||||
|
||||
func TestCanonicalizeTerminalText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ansi osc and controls",
|
||||
in: "\x1b]0;title\x07\x1b[31mred\x1b[0m\x00\nok",
|
||||
want: "red\nok",
|
||||
},
|
||||
{
|
||||
name: "noisy harness stream",
|
||||
in: "\x1b]0;noise\x07\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\n╭────╮\n│ │\nDownloading 10%\rDownloading 100%\nFINAL: deploy ready\n",
|
||||
want: "Status: running [time]\nDownloading [count]\nFINAL: deploy ready",
|
||||
},
|
||||
{
|
||||
name: "repeated blank collapse",
|
||||
in: "one\n\n\n two\n \n\t\nthree",
|
||||
want: "one\n\n two\n\nthree",
|
||||
},
|
||||
{
|
||||
name: "border only box drawing removal",
|
||||
in: "╭────────╮\n│ │\nimportant\n╰────────╯",
|
||||
want: "important",
|
||||
},
|
||||
{
|
||||
name: "carriage return progress coalesces final frame",
|
||||
in: "Downloading 10%\rDownloading 20%\rDownloading 100%\nDone",
|
||||
want: "Downloading [count]\nDone",
|
||||
},
|
||||
{
|
||||
name: "volatile timer duplicate collapse",
|
||||
in: "Status: running 12s\nStatus: running 13s\nStatus: running 01:23",
|
||||
want: "Status: running [time]",
|
||||
},
|
||||
{
|
||||
name: "duplicate status row collapse",
|
||||
in: "⠋ Building 1/4\n⠙ Building 2/4\n⠹ Building 3/4\nready",
|
||||
want: "Building [count]\nready",
|
||||
},
|
||||
{
|
||||
name: "preserve meaningful indented code and tables",
|
||||
in: " if elapsed == 12s {\n return value\n }\n| name | value |\n| a | 1 |",
|
||||
want: " if elapsed == 12s {\n return value\n }\n| name | value |\n| a | 1 |",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, truncated, _ := canonicalizeTerminalText(tc.in, 120)
|
||||
if truncated {
|
||||
t.Fatalf("unexpected truncation")
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeTerminalTextMaxLines(t *testing.T) {
|
||||
got, truncated, dropped := canonicalizeTerminalText("one\ntwo\nthree", 2)
|
||||
if !truncated {
|
||||
t.Fatalf("expected truncation")
|
||||
}
|
||||
if dropped == 0 {
|
||||
t.Fatalf("expected dropped bytes")
|
||||
}
|
||||
if got != "two\nthree" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProcessOutputStreamCanonicalByDefault(t *testing.T) {
|
||||
sess := NewSession(t.TempDir(), "test")
|
||||
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "")
|
||||
addChild(sess, c)
|
||||
c.recordWrite([]byte("\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\nresult\n"))
|
||||
host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24)
|
||||
|
||||
out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.Canonicalized {
|
||||
t.Fatalf("expected canonicalized output")
|
||||
}
|
||||
if out.Content != "Status: running [time]\nresult" {
|
||||
t.Fatalf("content = %q", out.Content)
|
||||
}
|
||||
if out.Cursor != nil || out.Rows != 0 || out.Cols != 0 || out.ScreenVersion != 0 || out.IdleMS != 0 {
|
||||
t.Fatalf("default output should be metadata-light: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProcessOutputRawReturnsStreamBytes(t *testing.T) {
|
||||
sess := NewSession(t.TempDir(), "test")
|
||||
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "")
|
||||
addChild(sess, c)
|
||||
c.recordWrite([]byte("\x1b[31mred\x1b[0m"))
|
||||
host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24)
|
||||
|
||||
out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "grid", Raw: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Mode != "stream" {
|
||||
t.Fatalf("raw grid mode should report stream semantics, got %q", out.Mode)
|
||||
}
|
||||
if out.Canonicalized {
|
||||
t.Fatalf("raw output should not be canonicalized")
|
||||
}
|
||||
if out.Content != "\x1b[31mred\x1b[0m" {
|
||||
t.Fatalf("content = %q", out.Content)
|
||||
}
|
||||
if out.NewOffset != int64(len(out.Content)) {
|
||||
t.Fatalf("new_offset=%d want %d", out.NewOffset, len(out.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProcessOutputCanonicalAfterRawRead(t *testing.T) {
|
||||
sess := NewSession(t.TempDir(), "test")
|
||||
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "")
|
||||
addChild(sess, c)
|
||||
c.recordWrite([]byte("\x1b[31mStatus: running 12s\x1b[0m\nStatus: running 13s\nDownloading 10%\rDownloading 100%\nFINAL: deploy ready\n"))
|
||||
host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24)
|
||||
|
||||
if _, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", Raw: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", MaxLines: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Content != "Status: running [time]\nDownloading [count]\nFINAL: deploy ready" {
|
||||
t.Fatalf("content = %q", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProcessOutputIncludeMetaRestoresFields(t *testing.T) {
|
||||
sess := NewSession(t.TempDir(), "test")
|
||||
c := newChildEntry("p1", "proc", KindCommand, nil, nil, "", "", "")
|
||||
addChild(sess, c)
|
||||
c.recordWrite([]byte("ok"))
|
||||
host := newToolHost(sess, nil, nil, preset.Set{}, nil, 80, 24)
|
||||
|
||||
out, err := host.GetProcessOutput("", mcp.ProcessOutputArgs{ProcessID: c.ID, Mode: "stream", IncludeMeta: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.ScreenVersion == 0 {
|
||||
t.Fatalf("screen_version missing with include_meta: %#v", out)
|
||||
}
|
||||
if !strings.Contains(out.Content, "ok") {
|
||||
t.Fatalf("content = %q", out.Content)
|
||||
}
|
||||
}
|
||||
@@ -532,6 +532,12 @@ func (c *Child) StreamRead(since int64) ([]byte, int64) {
|
||||
return out, end
|
||||
}
|
||||
|
||||
func (c *Child) StreamOffset() int64 {
|
||||
c.ringMu.Lock()
|
||||
defer c.ringMu.Unlock()
|
||||
return c.ringWrites
|
||||
}
|
||||
|
||||
func (c *Child) signal(sig syscall.Signal) error {
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
|
||||
@@ -65,6 +65,17 @@ type toolHost struct {
|
||||
timers *timerManager
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMCPContentBytes = 12_000
|
||||
maxMCPContentBytes = 65_536
|
||||
defaultMCPCanonicalLines = 120
|
||||
maxMCPCanonicalLines = 500
|
||||
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 +364,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,27 +376,48 @@ 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)
|
||||
}
|
||||
if mode == "" {
|
||||
mode = "grid"
|
||||
}
|
||||
if args.Raw {
|
||||
b, end := c.StreamRead(sinceOffset)
|
||||
content, contentBytes, truncated, truncatedBytes := capBytesTail(b, capLimit(args.MaxBytes, defaultMCPContentBytes))
|
||||
return mcp.ProcessOutput{
|
||||
Content: content,
|
||||
Mode: "stream",
|
||||
NewOffset: end,
|
||||
Status: string(c.Status()),
|
||||
ContentBytes: contentBytes,
|
||||
Truncated: truncated,
|
||||
TruncatedBytes: truncatedBytes,
|
||||
}, nil
|
||||
}
|
||||
out := mcp.ProcessOutput{
|
||||
Mode: mode,
|
||||
IdleMS: c.IdleMS(),
|
||||
Status: string(c.Status()),
|
||||
ScreenVersion: c.ScreenVersion(),
|
||||
Canonicalized: true,
|
||||
}
|
||||
if em := c.Emulator(); em != nil {
|
||||
if sc, err := em.ActiveScreen(); err == nil {
|
||||
out.ActiveScreen = activeScreenName(sc)
|
||||
if args.IncludeMeta {
|
||||
out.IdleMS = c.IdleMS()
|
||||
out.ScreenVersion = c.ScreenVersion()
|
||||
if em := c.Emulator(); em != nil {
|
||||
if sc, err := em.ActiveScreen(); err == nil {
|
||||
out.ActiveScreen = activeScreenName(sc)
|
||||
}
|
||||
if cur, err := em.Cursor(); err == nil {
|
||||
out.Cursor = &mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
|
||||
}
|
||||
cols, rows := em.Size()
|
||||
out.Cols, out.Rows = int(cols), int(rows)
|
||||
}
|
||||
if cur, err := em.Cursor(); err == nil {
|
||||
out.Cursor = mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
|
||||
}
|
||||
cols, rows := em.Size()
|
||||
out.Cols, out.Rows = int(cols), int(rows)
|
||||
}
|
||||
maxLines := canonicalLineLimit(args.MaxLines)
|
||||
switch mode {
|
||||
case "grid":
|
||||
em := c.Emulator()
|
||||
@@ -399,11 +431,21 @@ 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, lineTruncated, lineDroppedBytes := canonicalizeTerminalText(txt, maxLines)
|
||||
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextMiddle(content, capLimit(args.MaxBytes, defaultMCPContentBytes))
|
||||
if lineTruncated {
|
||||
out.Truncated = true
|
||||
out.TruncatedBytes += lineDroppedBytes
|
||||
}
|
||||
return out, nil
|
||||
case "stream":
|
||||
b, end := c.StreamRead(sinceOffset)
|
||||
out.Content = string(stripANSIBytes(nil, b))
|
||||
content, lineTruncated, lineDroppedBytes := canonicalizeTerminalText(string(b), maxLines)
|
||||
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextTail(content, capLimit(args.MaxBytes, defaultMCPContentBytes))
|
||||
if lineTruncated {
|
||||
out.Truncated = true
|
||||
out.TruncatedBytes += lineDroppedBytes
|
||||
}
|
||||
out.NewOffset = end
|
||||
return out, nil
|
||||
default:
|
||||
@@ -411,34 +453,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 +501,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 +644,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 +656,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 +875,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 +925,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 +933,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()
|
||||
@@ -1009,11 +1095,10 @@ func activeScreenName(s pkgvt.Screen) string {
|
||||
}
|
||||
}
|
||||
|
||||
// ansiRegexp strips CSI escape sequences and common single-character
|
||||
// controls (BEL, OSC terminators) from the stream. The vt emulator
|
||||
// already handles full rendering for grid mode; this is only for
|
||||
// stream-mode ANSI-stripped output.
|
||||
var ansiRegexp = regexp.MustCompile(`\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
|
||||
// ansiRegexp strips CSI/OSC escape sequences and common single-character
|
||||
// controls from the stream. The vt emulator already handles full
|
||||
// rendering for grid mode; this is only for stream-mode text output.
|
||||
var ansiRegexp = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
|
||||
|
||||
func stripANSI(s string) string {
|
||||
return ansiRegexp.ReplaceAllString(s, "")
|
||||
@@ -1043,12 +1128,68 @@ 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 canonicalLineLimit(requested int) int {
|
||||
if requested <= 0 {
|
||||
return defaultMCPCanonicalLines
|
||||
}
|
||||
if requested > maxMCPCanonicalLines {
|
||||
return maxMCPCanonicalLines
|
||||
}
|
||||
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
|
||||
// pattern match (WaitForPattern scrollback). Recognises the same
|
||||
// shapes the regex did:
|
||||
// - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR)
|
||||
// - `\x1b] ... (BEL|ST)` (OSC)
|
||||
// - `\x1b<final-byte>` for `@..._` (one-byte escapes)
|
||||
// - `\x07` (BEL)
|
||||
//
|
||||
@@ -1078,6 +1219,24 @@ func stripANSIBytes(dst, src []byte) []byte {
|
||||
continue
|
||||
}
|
||||
next := src[i+1]
|
||||
if next == ']' {
|
||||
j := i + 2
|
||||
for j < len(src) {
|
||||
if src[j] == 0x07 {
|
||||
i = j + 1
|
||||
break
|
||||
}
|
||||
if src[j] == 0x1b && j+1 < len(src) && src[j+1] == '\\' {
|
||||
i = j + 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
if j >= len(src) {
|
||||
i = len(src)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if next != '[' {
|
||||
// One-byte ESC sequence (`\x1b<final>` where final is
|
||||
// `@..._` per the regex; we drop anything that follows).
|
||||
@@ -1160,7 +1319,7 @@ func helpFor(topic string) mcp.HelpResponse {
|
||||
case "inspection":
|
||||
return mcp.HelpResponse{
|
||||
Topic: "inspection",
|
||||
Content: "get_process_output gives you the visible pane (grid mode) or a byte slice from since_offset (stream mode). list_processes is for the whole session. get_project_status batches everything you need to orient yourself.",
|
||||
Content: "get_process_output gives you canonical terminal text by default: the visible pane (grid mode) or recent stream text from since_offset (stream mode), with ANSI/control noise, borders, duplicate status churn, and volatile timers removed. Use raw:true only when you need diagnostic PTY bytes; include_meta:true restores cursor, geometry, and screen-version fields. list_processes is for the whole session. get_project_status batches everything you need to orient yourself.",
|
||||
RelatedTools: []string{"list_processes", "get_process_status", "get_process_output", "search_output", "wait_for_pattern", "get_project_status"},
|
||||
}
|
||||
case "io":
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/mcp"
|
||||
"github.com/hjbdev/patterm/internal/scratchpad"
|
||||
)
|
||||
|
||||
// mkChild builds a Child without starting a PTY. Use sparingly — the
|
||||
@@ -134,6 +135,42 @@ func TestWrapSubAgentPromptEmptyStaysEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPContentCapsPreferRecentStreamBytes(t *testing.T) {
|
||||
got, gotBytes, truncated, dropped := capBytesTail([]byte("abcdefghijklmnop"), 6)
|
||||
if got != "klmnop" || gotBytes != 6 || !truncated || dropped != 10 {
|
||||
t.Fatalf("capBytesTail = (%q, %d, %v, %d)", got, gotBytes, truncated, dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPGridCapKeepsHeadAndTail(t *testing.T) {
|
||||
got, gotBytes, truncated, dropped := capTextMiddle("abcdefghijklmnopqrstuvwxyz", 24)
|
||||
if gotBytes != 24 || !truncated || dropped != 2 {
|
||||
t.Fatalf("capTextMiddle metadata = (%d, %v, %d), content %q", gotBytes, truncated, dropped, got)
|
||||
}
|
||||
if !strings.Contains(got, "...[truncated]...") {
|
||||
t.Fatalf("capTextMiddle missing marker: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchpadReadPagesLargeContent(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
store, err := scratchpad.Open("test-project")
|
||||
if err != nil {
|
||||
t.Fatalf("scratchpad open: %v", err)
|
||||
}
|
||||
if _, err := store.Write("notes.md", "abcdefghijklmnopqrstuvwxyz", ""); err != nil {
|
||||
t.Fatalf("scratchpad write: %v", err)
|
||||
}
|
||||
h := &toolHost{pads: store}
|
||||
res, err := h.ScratchpadRead(mcp.ScratchpadReadArgs{Name: "notes.md", Offset: 5, MaxBytes: 7})
|
||||
if err != nil {
|
||||
t.Fatalf("ScratchpadRead: %v", err)
|
||||
}
|
||||
if res.Content != "fghijkl" || !res.Truncated || res.NextOffset != 12 || res.TotalBytes != 26 {
|
||||
t.Fatalf("ScratchpadRead result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpLifecycleTopicCoversCleanup(t *testing.T) {
|
||||
resp := helpFor("lifecycle")
|
||||
if resp.Topic != "lifecycle" {
|
||||
|
||||
@@ -90,6 +90,8 @@ func TestStripANSIBytesEquivalence(t *testing.T) {
|
||||
cases := []string{
|
||||
"hello world",
|
||||
"\x1b[31mred\x1b[0m text",
|
||||
"\x1b]0;title\x07after osc",
|
||||
"\x1b]2;title\x1b\\after st",
|
||||
"line1\nline2\r\nline3",
|
||||
"bell\x07ish",
|
||||
"weird \x1bA escape",
|
||||
|
||||
@@ -561,14 +561,16 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
|
||||
if t.status != timerStatusPending && t.status != timerStatusPaused {
|
||||
continue
|
||||
}
|
||||
body, bodyTruncated := timerBodyPreview(t.body)
|
||||
info := mcp.TimerInfo{
|
||||
ID: t.id,
|
||||
Label: t.label,
|
||||
Body: t.body,
|
||||
Kind: string(t.kind),
|
||||
Status: t.status,
|
||||
OwnerID: t.ownerID,
|
||||
WatchedIDs: append([]string(nil), t.watched...),
|
||||
ID: t.id,
|
||||
Label: t.label,
|
||||
Body: body,
|
||||
BodyTruncated: bodyTruncated,
|
||||
Kind: string(t.kind),
|
||||
Status: t.status,
|
||||
OwnerID: t.ownerID,
|
||||
WatchedIDs: append([]string(nil), t.watched...),
|
||||
}
|
||||
if t.status == timerStatusPending && !t.firesAt.IsZero() {
|
||||
info.FiresAtUnixMS = t.firesAt.UnixMilli()
|
||||
@@ -581,6 +583,14 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
func timerBodyPreview(body string) (string, bool) {
|
||||
const max = 500
|
||||
if len(body) <= max {
|
||||
return body, false
|
||||
}
|
||||
return body[:max], true
|
||||
}
|
||||
|
||||
// activeForChild returns the nearest pending or paused timer attached
|
||||
// to child id (either owned by it or watching it). Used by the sidebar
|
||||
// for the "⏱ 12s" indicator. nil when none.
|
||||
|
||||
62
internal/harness/scenarios/canonical_output_noise.json
Normal file
62
internal/harness/scenarios/canonical_output_noise.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "canonical_output_noise",
|
||||
"steps": [
|
||||
{
|
||||
"type": "mcp_call",
|
||||
"method": "spawn_process",
|
||||
"params": {
|
||||
"kind": "command",
|
||||
"argv": [
|
||||
"sh",
|
||||
"-lc",
|
||||
"printf '\\033[31mStatus: running 12s\\033[0m\\nStatus: running 13s\\n╭────╮\\n│ │\\nDownloading 10%%\\rDownloading 100%%\\nFINAL: deploy ready\\n'; sleep 5"
|
||||
],
|
||||
"name": "noisy"
|
||||
},
|
||||
"save_as": "proc"
|
||||
},
|
||||
{
|
||||
"type": "wait_until_mcp",
|
||||
"method": "get_process_output",
|
||||
"params": {
|
||||
"process_id": "{{proc.process_id}}",
|
||||
"mode": "stream",
|
||||
"raw": true,
|
||||
"max_lines": 20
|
||||
},
|
||||
"path": "content",
|
||||
"contains": "FINAL: deploy ready",
|
||||
"timeout_ms": 5000,
|
||||
"save_as": "raw"
|
||||
},
|
||||
{
|
||||
"type": "assert_saved",
|
||||
"from": "raw",
|
||||
"path": "content",
|
||||
"contains": "FINAL: deploy ready"
|
||||
},
|
||||
{
|
||||
"type": "mcp_call",
|
||||
"method": "get_process_output",
|
||||
"params": {
|
||||
"process_id": "{{proc.process_id}}",
|
||||
"mode": "stream",
|
||||
"since_offset": 0,
|
||||
"max_lines": 20
|
||||
},
|
||||
"save_as": "canonical"
|
||||
},
|
||||
{
|
||||
"type": "assert_saved",
|
||||
"from": "canonical",
|
||||
"path": "content",
|
||||
"equals": "Status: running [time]\nDownloading [count]\nFINAL: deploy ready"
|
||||
},
|
||||
{
|
||||
"type": "assert_saved",
|
||||
"from": "canonical",
|
||||
"path": "canonicalized",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -134,16 +134,16 @@ func (h *blockingToolHost) ListProcesses(string, string) []ProcessInfo { return
|
||||
func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) {
|
||||
return ProcessStatus{ProcessInfo: ProcessInfo{ID: "p_fast", Status: "running"}}, nil
|
||||
}
|
||||
func (h *blockingToolHost) GetProjectStatus(string) (ProjectStatus, error) {
|
||||
func (h *blockingToolHost) GetProjectStatus(string, bool) (ProjectStatus, error) {
|
||||
return ProjectStatus{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) GetProcessOutput(string, string, string, int64) (ProcessOutput, error) {
|
||||
func (h *blockingToolHost) GetProcessOutput(string, ProcessOutputArgs) (ProcessOutput, error) {
|
||||
return ProcessOutput{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) GetProcessRawOutput(string, string, int64) (RawOutput, error) {
|
||||
func (h *blockingToolHost) GetProcessRawOutput(string, RawOutputArgs) (RawOutput, error) {
|
||||
return RawOutput{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) SearchOutput(string, string, string, string, int) (SearchResult, error) {
|
||||
func (h *blockingToolHost) SearchOutput(string, SearchOutputArgs) (SearchResult, error) {
|
||||
return SearchResult{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) WaitForPattern(string, string, string, float64, string) (bool, string, error) {
|
||||
@@ -178,13 +178,13 @@ func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil }
|
||||
func (h *blockingToolHost) ScratchpadRead(string) (string, string, error) {
|
||||
return "", "", nil
|
||||
func (h *blockingToolHost) ScratchpadRead(ScratchpadReadArgs) (ScratchpadReadResult, error) {
|
||||
return ScratchpadReadResult{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil }
|
||||
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil }
|
||||
func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} }
|
||||
func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} }
|
||||
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }
|
||||
|
||||
@@ -3,6 +3,8 @@ package mcp
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/scratchpad"
|
||||
)
|
||||
|
||||
// MCP protocol surface. The patterm server originally exposed each
|
||||
@@ -43,7 +45,7 @@ var serverInfo = map[string]any{
|
||||
// up as sub-agents and won't be tied into the patterm lifecycle.
|
||||
//
|
||||
// Keep this short — clients vary in how much they surface to the LLM.
|
||||
const serverInstructions = "You are already running INSIDE patterm; the `patterm` MCP server is connected over the same stdio MCP transport you use for any other MCP server. Use the MCP tools you see in tools/list — do NOT (a) try to launch `patterm` or `patterm mcp-stdio` yourself, (b) poke the Unix socket through perl / nc / socat / curl, or (c) shell out to `claude` / `codex` / `opencode` to start a peer. Any of those bypasses caller-identity and the new agent will land as a stray top-level tab instead of a child under you. Start with `whoami` for your role and the full tool list, then `help('topics')` for orientation. `spawn_agent` is the only correct way to start a sub-agent; `spawn_process` is for non-LLM commands; `list_processes` / `get_process_output` inspect them; `send_input` / `send_message` drive them. Whatever you spawn is yours to `close_process` when done. When you `send_message` a sub-agent, its reply comes back into YOUR pane as `[sub-agent:<name>] …`, not into the sub-agent's output — to wait for it, use `timer_fire_when_idle_any([sub_agent])` and then read your own pane; do NOT `wait_for_pattern` on the sub-agent, that will deadlock until timeout."
|
||||
const serverInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies."
|
||||
|
||||
// toolDescriptor is the shape returned by `tools/list`. inputSchema is
|
||||
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
|
||||
@@ -76,37 +78,41 @@ func objectSchema(properties map[string]any, required []string) map[string]any {
|
||||
}
|
||||
|
||||
func stringProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "string"}
|
||||
}
|
||||
|
||||
func numberProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "number", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "number"}
|
||||
}
|
||||
|
||||
func integerProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "integer", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "integer"}
|
||||
}
|
||||
|
||||
func booleanProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "boolean", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "boolean"}
|
||||
}
|
||||
|
||||
func arrayOfStringsProp(desc string) map[string]any {
|
||||
_ = desc
|
||||
return map[string]any{
|
||||
"type": "array",
|
||||
"description": desc,
|
||||
"items": map[string]any{"type": "string"},
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string"},
|
||||
}
|
||||
}
|
||||
|
||||
// toolCatalog is the full list advertised via tools/list. Descriptions
|
||||
// are intentionally short — clients are expected to fetch help() for
|
||||
// detail. Schemas mirror the param structs in tools.go.
|
||||
func toolCatalog() []toolDescriptor {
|
||||
return []toolDescriptor{
|
||||
func toolCatalog(role CallerRole) []toolDescriptor {
|
||||
tools := []toolDescriptor{
|
||||
{
|
||||
Name: "spawn_agent",
|
||||
Description: "Spawn a sub-agent from an agent preset and optionally seed it with initial instructions. This is the ONLY correct way to start a sub-agent under you — do not shell out to `claude` / `codex` / `opencode` and do not poke patterm's Unix socket via perl / nc / socat. Either bypasses caller identity and the new agent lands as a stray top-level tab instead of your child. Caller owns lifecycle: when the sub-agent's work is done (it reports back via send_message, or you no longer need it), call close_process on its process_id to free the pane and tear down the PTY. See help('spawning') and help('lifecycle').",
|
||||
Description: "Spawn a sub-agent from an agent preset.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."),
|
||||
"agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."),
|
||||
@@ -115,14 +121,14 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "spawn_process",
|
||||
Description: "Spawn a process: a terminal, a process preset, or a freeform argv command. Caller owns lifecycle: when the process is no longer needed, call close_process to remove its entry (live children are SIGKILL'd first). See help('lifecycle').",
|
||||
Description: "Spawn a terminal, process preset, or argv command.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"kind": stringProp("\"terminal\" or \"command\"."),
|
||||
"preset": stringProp("Process preset name (mutually exclusive with argv)."),
|
||||
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Argv vector for freeform commands."},
|
||||
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"name": stringProp("Display name for the pane."),
|
||||
"working_dir": stringProp("Working directory for the spawned process."),
|
||||
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "Extra environment variables."},
|
||||
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
|
||||
"shell": booleanProp("Run argv through sh -lc."),
|
||||
}, nil),
|
||||
},
|
||||
@@ -188,23 +194,30 @@ func toolCatalog() []toolDescriptor {
|
||||
{
|
||||
Name: "get_project_status",
|
||||
Description: "One-shot orientation: project, caller, processes, scratchpads.",
|
||||
InputSchema: objectSchema(nil, nil),
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"include_tools": booleanProp("Include available_tools in caller metadata."),
|
||||
}, nil),
|
||||
},
|
||||
{
|
||||
Name: "get_process_output",
|
||||
Description: "Read rendered grid (\"grid\") or ANSI-stripped stream (\"stream\") output, with screen-version watermark.",
|
||||
Description: "Read canonical terminal text by default: visible grid (\"grid\") or recent stream (\"stream\") with ANSI/control noise, borders, duplicate status churn, and volatile timers removed. Set raw=true only for diagnostic ANSI-preserved PTY bytes.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"mode": stringProp("\"grid\" (default) or \"stream\"."),
|
||||
"since_offset": integerProp("Watermark offset from a previous call."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
"max_lines": integerProp("Maximum canonical lines to return (default 120, max 500)."),
|
||||
"raw": booleanProp("Return raw ANSI-preserved stream bytes instead of canonical text."),
|
||||
"include_meta": booleanProp("Include verbose cursor, geometry, active screen, idle, and screen-version metadata."),
|
||||
}, []string{"process_id"}),
|
||||
},
|
||||
{
|
||||
Name: "get_process_raw_output",
|
||||
Description: "Read the raw ANSI byte stream since since_offset.",
|
||||
Description: "Compatibility alias for raw=true get_process_output: read the raw ANSI byte stream since since_offset.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"since_offset": integerProp("Byte offset from a previous call."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
}, []string{"process_id"}),
|
||||
},
|
||||
{
|
||||
@@ -214,12 +227,13 @@ func toolCatalog() []toolDescriptor {
|
||||
"process_id": stringProp("Target process id."),
|
||||
"pattern": stringProp("Regex pattern."),
|
||||
"kind": stringProp("\"rendered\" (default) or \"raw\"."),
|
||||
"limit": integerProp("Max matches (default 20)."),
|
||||
"limit": integerProp("Max matches (default 10)."),
|
||||
"max_bytes": integerProp("Max bytes per returned match line."),
|
||||
}, []string{"process_id", "pattern"}),
|
||||
},
|
||||
{
|
||||
Name: "wait_for_pattern",
|
||||
Description: "Block until pattern appears in the TARGET process's own output, or timeout elapses. Use this for waiting on text the target itself will emit (a shell prompt, a build's \"tests passed\" line, etc.). Anti-pattern: do NOT use this to wait for a sub-agent's reply to send_message — replies are routed into the CALLER's pane tagged `[sub-agent:<name>]`, not into the sub-agent's output, so this call will spin to timeout. For sub-agent coordination use `timer_fire_when_idle_any` and then read your own pane.",
|
||||
Description: "Block until pattern appears in the target process output.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"pattern": stringProp("Regex pattern."),
|
||||
@@ -238,18 +252,19 @@ func toolCatalog() []toolDescriptor {
|
||||
Name: "send_input",
|
||||
Description: "Type text, paste a block, or fire a named key into a process. Optional tail-after-send.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
|
||||
"text": stringProp("Text payload for kind=text/paste."),
|
||||
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
|
||||
"submit": booleanProp("Whether to append a submit keystroke."),
|
||||
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
|
||||
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
|
||||
"process_id": stringProp("Target process id."),
|
||||
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
|
||||
"text": stringProp("Text payload for kind=text/paste."),
|
||||
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
|
||||
"submit": booleanProp("Whether to append a submit keystroke."),
|
||||
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
|
||||
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
|
||||
"tail_max_bytes": integerProp("Maximum bytes in returned tail."),
|
||||
}, []string{"process_id", "kind"}),
|
||||
},
|
||||
{
|
||||
Name: "send_message",
|
||||
Description: "Deliver a text message to another process as orchestrator-owned input. Fire-and-forget: returns immediately, without waiting for the recipient to read or act. If the recipient replies via send_message, that reply arrives in YOUR pane tagged `[sub-agent:<name>]` (child→parent) or `[orchestrator]` (parent→child) — NOT in the recipient's output. To wait for a sub-agent's reply, schedule `timer_fire_when_idle_any([sub_agent_id], body=…)` and then read your own pane when the timer fires. Do not `wait_for_pattern` on the recipient for a reply; it will deadlock.",
|
||||
Description: "Send a tagged message to a parent or child process.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"target_process_id": stringProp("Recipient process id."),
|
||||
"message": stringProp("Message body."),
|
||||
@@ -283,7 +298,7 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "timer_fire_when_idle_any",
|
||||
Description: "Canonical way to wait for a sub-agent to finish working: send_message the sub-agent, then schedule this with watched=[sub_agent_id]; when it fires, the reply is already sitting in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
|
||||
Description: "Fire when any watched process becomes idle.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"watched": arrayOfStringsProp("Process ids to watch."),
|
||||
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
|
||||
@@ -294,7 +309,7 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "timer_fire_when_idle_all",
|
||||
Description: "Canonical way to wait for several sub-agents to finish working in parallel: send_message each one, then schedule this with watched=[…ids]; when it fires, each reply is in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
|
||||
Description: "Fire when all watched processes are idle.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"watched": arrayOfStringsProp("Process ids to watch."),
|
||||
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
|
||||
@@ -338,7 +353,9 @@ func toolCatalog() []toolDescriptor {
|
||||
Name: "scratchpad_read",
|
||||
Description: "Read a scratchpad entry, returning content and revision.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"name": stringProp("Scratchpad name."),
|
||||
"name": stringProp("Scratchpad name."),
|
||||
"offset": integerProp("Byte offset to start reading."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
}, []string{"name"}),
|
||||
},
|
||||
{
|
||||
@@ -367,8 +384,10 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "whoami",
|
||||
Description: "Return the caller's identity, role, parent, project metadata, and available tools.",
|
||||
InputSchema: objectSchema(nil, nil),
|
||||
Description: "Return caller identity, role, parent, and project metadata.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"include_tools": booleanProp("Include full available tool list."),
|
||||
}, nil),
|
||||
},
|
||||
{
|
||||
Name: "help",
|
||||
@@ -378,6 +397,16 @@ func toolCatalog() []toolDescriptor {
|
||||
}, nil),
|
||||
},
|
||||
}
|
||||
if role != RoleSubAgent {
|
||||
return tools
|
||||
}
|
||||
filtered := tools[:0]
|
||||
for _, tool := range tools {
|
||||
if tool.Name != "spawn_agent" {
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// handleProtocolMethod handles MCP protocol-level methods. Returns
|
||||
@@ -416,7 +445,14 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
|
||||
return map[string]any{}, true, 0, "", nil
|
||||
|
||||
case "tools/list":
|
||||
return map[string]any{"tools": toolCatalog()}, true, 0, "", nil
|
||||
role := RoleOrchestrator
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host != nil {
|
||||
role = host.CallerRole(callerID)
|
||||
}
|
||||
return map[string]any{"tools": toolCatalog(role)}, true, 0, "", nil
|
||||
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
@@ -472,25 +508,12 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
|
||||
return nil, false, 0, "", nil
|
||||
}
|
||||
|
||||
// wrapToolResult turns a structured tool result into an MCP tools/call
|
||||
// response. Plain strings (e.g. "ok") become text content; structured
|
||||
// values are JSON-encoded into a single text block and also exposed
|
||||
// under structuredContent so capable clients can read the shape.
|
||||
// wrapToolResult turns a tool result into an MCP tools/call response.
|
||||
// Structured values are exposed once under structuredContent; content
|
||||
// carries only a short model-readable summary to avoid duplicating
|
||||
// large JSON payloads into the transcript.
|
||||
func wrapToolResult(result any) map[string]any {
|
||||
var text string
|
||||
switch v := result.(type) {
|
||||
case nil:
|
||||
text = "ok"
|
||||
case string:
|
||||
text = v
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
text = fmt.Sprintf("%v", v)
|
||||
} else {
|
||||
text = string(b)
|
||||
}
|
||||
}
|
||||
text := summarizeToolResult(result)
|
||||
out := map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": text}},
|
||||
"isError": false,
|
||||
@@ -505,3 +528,70 @@ func wrapToolResult(result any) map[string]any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func summarizeToolResult(result any) string {
|
||||
switch v := result.(type) {
|
||||
case nil:
|
||||
return "ok"
|
||||
case string:
|
||||
return v
|
||||
case ProcessInfo:
|
||||
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
|
||||
case []ProcessInfo:
|
||||
return fmt.Sprintf("%d processes", len(v))
|
||||
case ProcessStatus:
|
||||
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
|
||||
case ProjectStatus:
|
||||
return fmt.Sprintf("%d processes, %d scratchpads", len(v.Processes), len(v.Scratchpads))
|
||||
case ProcessOutput:
|
||||
return outputSummary(v.Mode, v.ContentBytes, v.Truncated, v.NewOffset)
|
||||
case RawOutput:
|
||||
return outputSummary("raw", v.ContentBytes, v.Truncated, v.NewOffset)
|
||||
case SearchResult:
|
||||
if v.Truncated {
|
||||
return fmt.Sprintf("%d matches (truncated)", len(v.Matches))
|
||||
}
|
||||
return fmt.Sprintf("%d matches", len(v.Matches))
|
||||
case SendInputResult:
|
||||
if v.Tail != nil {
|
||||
return "ok; tail included"
|
||||
}
|
||||
return "ok"
|
||||
case TimerHandle:
|
||||
return "timer " + v.ID
|
||||
case TimerFireWhenIdleResponse:
|
||||
if v.ID != "" {
|
||||
return fmt.Sprintf("%s timer %s", v.Status, v.ID)
|
||||
}
|
||||
return v.Status
|
||||
case []TimerInfo:
|
||||
return fmt.Sprintf("%d timers", len(v))
|
||||
case []scratchpad.Entry:
|
||||
return fmt.Sprintf("%d scratchpads", len(v))
|
||||
case ScratchpadReadResult:
|
||||
if v.Truncated {
|
||||
return fmt.Sprintf("%d/%d bytes from offset %d", v.ContentBytes, v.TotalBytes, v.Offset)
|
||||
}
|
||||
return fmt.Sprintf("%d bytes", v.ContentBytes)
|
||||
case WhoAmI:
|
||||
if v.ProcessID == "" {
|
||||
return string(v.Role)
|
||||
}
|
||||
return fmt.Sprintf("%s %s", v.ProcessID, v.Role)
|
||||
case HelpResponse:
|
||||
return fmt.Sprintf("help: %s", v.Topic)
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
|
||||
func outputSummary(mode string, bytes int, truncated bool, offset int64) string {
|
||||
s := fmt.Sprintf("%s output: %d bytes", mode, bytes)
|
||||
if offset > 0 {
|
||||
s += fmt.Sprintf(", offset %d", offset)
|
||||
}
|
||||
if truncated {
|
||||
s += " (truncated)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -43,6 +44,9 @@ func TestInitializeReturnsCapabilities(t *testing.T) {
|
||||
if !ok || instructions == "" {
|
||||
t.Fatalf("instructions missing or wrong type: %+v", parsed.Result)
|
||||
}
|
||||
if len(instructions) > 320 {
|
||||
t.Fatalf("instructions too verbose: %d chars", len(instructions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializedNotificationSuppressesResponse(t *testing.T) {
|
||||
@@ -74,6 +78,9 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
|
||||
if parsed.Error != nil {
|
||||
t.Fatalf("tools/list returned error: %+v", parsed.Error)
|
||||
}
|
||||
if len(resp) > 12000 {
|
||||
t.Fatalf("tools/list response too large: %d bytes", len(resp))
|
||||
}
|
||||
tools, ok := parsed.Result["tools"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("tools not array: %+v", parsed.Result)
|
||||
@@ -112,6 +119,27 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) {
|
||||
result := ProcessOutput{
|
||||
Content: strings.Repeat("x", 1024),
|
||||
Mode: "stream",
|
||||
NewOffset: 2048,
|
||||
ContentBytes: 1024,
|
||||
}
|
||||
wrapped := wrapToolResult(result)
|
||||
if wrapped["structuredContent"] == nil {
|
||||
t.Fatalf("structuredContent missing: %#v", wrapped)
|
||||
}
|
||||
content := wrapped["content"].([]map[string]any)
|
||||
text := content[0]["text"].(string)
|
||||
if strings.Contains(text, result.Content) {
|
||||
t.Fatalf("content duplicated structured payload: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "stream output") {
|
||||
t.Fatalf("summary text should identify output, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingReturnsEmptyObject(t *testing.T) {
|
||||
s := &Server{}
|
||||
req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`)
|
||||
|
||||
@@ -74,10 +74,10 @@ type ToolHost interface {
|
||||
// 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)
|
||||
GetProjectStatus(callerID string, includeTools bool) (ProjectStatus, error)
|
||||
GetProcessOutput(callerID string, args ProcessOutputArgs) (ProcessOutput, error)
|
||||
GetProcessRawOutput(callerID string, args RawOutputArgs) (RawOutput, error)
|
||||
SearchOutput(callerID string, args SearchOutputArgs) (SearchResult, error)
|
||||
WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error)
|
||||
GetProcessPorts(callerID, processID string) ([]PortSighting, error)
|
||||
|
||||
@@ -98,13 +98,13 @@ type ToolHost interface {
|
||||
|
||||
// Scratchpads.
|
||||
ScratchpadList() ([]scratchpad.Entry, error)
|
||||
ScratchpadRead(name string) (content string, revision string, err error)
|
||||
ScratchpadRead(args ScratchpadReadArgs) (ScratchpadReadResult, error)
|
||||
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
|
||||
ScratchpadAppend(name, content string) error
|
||||
ScratchpadDelete(name string) error
|
||||
|
||||
// Meta.
|
||||
WhoAmI(callerID string) WhoAmI
|
||||
WhoAmI(callerID string, includeTools bool) WhoAmI
|
||||
Help(callerID, topic string) HelpResponse
|
||||
}
|
||||
|
||||
@@ -157,32 +157,60 @@ type ProjectStatus struct {
|
||||
Scratchpads []scratchpad.Entry `json:"scratchpads"`
|
||||
}
|
||||
|
||||
type ProjectStatusArgs struct {
|
||||
IncludeTools bool `json:"include_tools"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// ProcessOutput is the get_process_output payload. By default it is
|
||||
// canonical text with light metadata; include_meta restores screen
|
||||
// geometry + version, and raw requests return stream bytes.
|
||||
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"`
|
||||
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,omitempty"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ScreenVersion int64 `json:"screen_version,omitempty"`
|
||||
ContentBytes int `json:"content_bytes,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TruncatedBytes int `json:"truncated_bytes,omitempty"`
|
||||
Canonicalized bool `json:"canonicalized,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessOutputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Mode string `json:"mode"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
MaxLines int `json:"max_lines"`
|
||||
Raw bool `json:"raw"`
|
||||
IncludeMeta bool `json:"include_meta"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Content string `json:"content"`
|
||||
NewOffset int64 `json:"new_offset"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ContentBytes int `json:"content_bytes,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TruncatedBytes int `json:"truncated_bytes,omitempty"`
|
||||
}
|
||||
|
||||
type RawOutputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
}
|
||||
|
||||
// SearchResult is search_output's payload.
|
||||
@@ -191,6 +219,14 @@ type SearchResult struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type SearchOutputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
Kind string `json:"kind"`
|
||||
Limit int `json:"limit"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
}
|
||||
|
||||
type SearchMatch struct {
|
||||
LineNo int `json:"line_no"`
|
||||
Text string `json:"text"`
|
||||
@@ -245,6 +281,7 @@ type TimerInfo struct {
|
||||
ID string `json:"timer_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
BodyTruncated bool `json:"body_truncated,omitempty"`
|
||||
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
|
||||
Status string `json:"status"` // "pending" | "paused"
|
||||
OwnerID string `json:"owner_process_id"`
|
||||
@@ -281,13 +318,14 @@ type SpawnProcessArgs struct {
|
||||
// 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"
|
||||
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"
|
||||
TailMaxBytes int `json:"tail_max_bytes"`
|
||||
}
|
||||
|
||||
// SendInputResult is the return shape of send_input.
|
||||
@@ -306,6 +344,27 @@ type WhoAmI struct {
|
||||
AvailableTools []string `json:"available_tools"`
|
||||
}
|
||||
|
||||
type WhoAmIArgs struct {
|
||||
IncludeTools bool `json:"include_tools"`
|
||||
}
|
||||
|
||||
type ScratchpadReadArgs struct {
|
||||
Name string `json:"name"`
|
||||
Offset int `json:"offset"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
}
|
||||
|
||||
type ScratchpadReadResult struct {
|
||||
Content string `json:"content"`
|
||||
Revision string `json:"revision"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
NextOffset int `json:"next_offset,omitempty"`
|
||||
ContentBytes int `json:"content_bytes,omitempty"`
|
||||
TotalBytes int `json:"total_bytes,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TruncatedBytes int `json:"truncated_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// HelpResponse is the help return shape.
|
||||
type HelpResponse struct {
|
||||
Topic string `json:"topic"`
|
||||
@@ -507,61 +566,51 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
return st, 0, "", nil
|
||||
|
||||
case "get_project_status":
|
||||
ps, err := h.GetProjectStatus(callerID)
|
||||
var p ProjectStatusArgs
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
ps, err := h.GetProjectStatus(callerID, p.IncludeTools)
|
||||
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 int64 `json:"since_offset"`
|
||||
}
|
||||
var p ProcessOutputArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Mode == "" {
|
||||
p.Mode = "grid"
|
||||
}
|
||||
out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset)
|
||||
out, err := h.GetProcessOutput(callerID, p)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return out, 0, "", nil
|
||||
|
||||
case "get_process_raw_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
}
|
||||
var p RawOutputArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
out, err := h.GetProcessRawOutput(callerID, p.ProcessID, p.SinceOffset)
|
||||
out, err := h.GetProcessRawOutput(callerID, p)
|
||||
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"`
|
||||
}
|
||||
var p SearchOutputArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Limit <= 0 {
|
||||
p.Limit = 20
|
||||
p.Limit = 10
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = "rendered"
|
||||
}
|
||||
res, err := h.SearchOutput(callerID, p.ProcessID, p.Pattern, p.Kind, p.Limit)
|
||||
res, err := h.SearchOutput(callerID, p)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
@@ -731,17 +780,15 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
return entries, 0, "", nil
|
||||
|
||||
case "scratchpad_read":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var p ScratchpadReadArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
content, rev, err := h.ScratchpadRead(p.Name)
|
||||
res, err := h.ScratchpadRead(p)
|
||||
if err != nil {
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return map[string]any{"content": content, "revision": rev}, 0, "", nil
|
||||
return res, 0, "", nil
|
||||
|
||||
case "scratchpad_write":
|
||||
var p struct {
|
||||
@@ -790,7 +837,9 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
return map[string]any{"ok": true}, 0, "", nil
|
||||
|
||||
case "whoami":
|
||||
return h.WhoAmI(callerID), 0, "", nil
|
||||
var p WhoAmIArgs
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
return h.WhoAmI(callerID, p.IncludeTools), 0, "", nil
|
||||
|
||||
case "help":
|
||||
var p struct {
|
||||
|
||||
Reference in New Issue
Block a user