2 Commits

Author SHA1 Message Date
45263d59f8 aggressive token saving attempts 2026-05-29 14:23:09 +01:00
51aac9f447 Reduce MCP token usage 2026-05-29 13:16:05 +01:00
31 changed files with 964 additions and 1051 deletions

View File

@@ -13,9 +13,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Changed ### Changed
- The tab bar now shows each visible agent tab's own summary instead - The tab bar now shows each visible agent tab's own summary instead
of only rendering the focused tab's summary. of only rendering the focused tab's summary.
- Grid-mode `get_process_output` now returns whitespace-normalized - `get_process_output` now returns aggressively canonical terminal text
text to avoid sending padded terminal rows and repeated blank lines by default, removing ANSI/control noise, decorative borders, duplicate
over MCP. 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 ### Fixed
- Injected agent input now sends the submit Enter as a separated, - Injected agent input now sends the submit Enter as a separated,

View File

@@ -0,0 +1 @@
- [ ] Pasting into codex is no longer clean, it sends loads of messages rather than one clean paste.

View File

@@ -108,7 +108,7 @@ func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthro
} }
defer em.Close() defer em.Close()
child, err := pty.Start(argv, nil, "", cols, rows) child, err := pty.Start(argv, nil, cols, rows)
if err != nil { if err != nil {
return fmt.Errorf("pty: %w", err) return fmt.Errorf("pty: %w", err)
} }

View File

@@ -161,21 +161,6 @@ func Run(ctx context.Context, opts Options) error {
// ctx is cancelled. // ctx is cancelled.
go sess.runClassifier(ctx) go sess.runClassifier(ctx)
core := &headlessCore{
projectDir: opts.ProjectDir,
projectKey: opts.ProjectKey,
presets: presets,
settings: appSettings,
pads: pads,
trustStore: trustStore,
persistStore: persistStore,
mcpSrv: mcpSrv,
sess: sess,
launcher: launcher,
host: host,
}
_ = core
st := &uiState{ st := &uiState{
sess: sess, sess: sess,
presets: presets, presets: presets,
@@ -186,12 +171,6 @@ func Run(ctx context.Context, opts Options) error {
timers: host.timers, timers: host.timers,
hostCols: cols, hostCols: cols,
hostRows: rows, hostRows: rows,
view: ClientView{
ID: "loopback",
ProjectKey: opts.ProjectKey,
Cols: cols,
Rows: rows,
},
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
metrics: metrics, metrics: metrics,
settings: appSettings, settings: appSettings,
@@ -273,7 +252,6 @@ func Run(ctx context.Context, opts Options) error {
} }
st.dimsMu.Lock() st.dimsMu.Lock()
st.hostCols, st.hostRows = c, r st.hostCols, st.hostRows = c, r
st.view.Resize(c, r)
l := st.layoutLocked() l := st.layoutLocked()
st.dimsMu.Unlock() st.dimsMu.Unlock()
st.mu.Lock() st.mu.Lock()
@@ -430,7 +408,6 @@ type uiState struct {
outMu sync.Mutex outMu sync.Mutex
mu sync.Mutex mu sync.Mutex
view ClientView
palette *paletteState palette *paletteState
focusedID string focusedID string
focusedName string focusedName string
@@ -597,21 +574,6 @@ func (st *uiState) promptTrust(processID, presetName, reason string) {
st.drawStatusLine() st.drawStatusLine()
} }
func (st *uiState) focusChildLocked(c *Child) {
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.view.FocusChild(c.ID)
}
func (st *uiState) focusPadLocked(name string) {
st.view.FocusPad(name)
st.focusedPad = st.view.FocusedPad
st.focusedID = st.view.FocusedID
st.padOffset = st.view.PadOffset
st.padOffsetName = st.view.PadOffsetName
}
// focusProcess is the SPEC §7 select_process hook. Routes through the // focusProcess is the SPEC §7 select_process hook. Routes through the
// normal focus-change path; only takes effect if the process exists. // normal focus-change path; only takes effect if the process exists.
func (st *uiState) focusProcess(processID string) { func (st *uiState) focusProcess(processID string) {
@@ -624,7 +586,9 @@ func (st *uiState) focusProcess(processID string) {
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
leavingPad := st.focusedPad != "" leavingPad := st.focusedPad != ""
st.focusChildLocked(c) st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) st.updateActiveAgentLocked(c)
r := newViewportRenderer(layout) r := newViewportRenderer(layout)
r.SetChildOnAlt(onAlt) r.SetChildOnAlt(onAlt)
@@ -687,7 +651,12 @@ func (st *uiState) focusScratchpad(name string) {
} }
st.marquee.reset() st.marquee.reset()
st.mu.Lock() st.mu.Lock()
st.focusPadLocked(name) if st.padOffsetName != name {
st.padOffset = 0
st.padOffsetName = name
}
st.focusedPad = name
st.focusedID = ""
st.focusedName = name st.focusedName = name
st.renderer = nil st.renderer = nil
st.mu.Unlock() st.mu.Unlock()
@@ -742,7 +711,8 @@ func (st *uiState) restartFocusedCommand(processID string) {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
renderer := newViewportRenderer(layout) renderer := newViewportRenderer(layout)
st.mu.Lock() st.mu.Lock()
st.focusChildLocked(c) st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.renderer = renderer st.renderer = renderer
st.repaintNextPTY = c.ID st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 2 st.repaintNextPTYBudget = 2
@@ -777,7 +747,6 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
} }
if c.ParentID == "" { if c.ParentID == "" {
st.activeAgentID = c.ID st.activeAgentID = c.ID
st.view.ActiveAgentID = c.ID
return return
} }
// Walk up to the top-level agent. // Walk up to the top-level agent.
@@ -791,7 +760,6 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
} }
if root.Kind == KindAgent && root.ParentID == "" { if root.Kind == KindAgent && root.ParentID == "" {
st.activeAgentID = root.ID st.activeAgentID = root.ID
st.view.ActiveAgentID = root.ID
} }
} }
@@ -854,7 +822,9 @@ func (st *uiState) OnChildSpawned(c *Child) {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
st.focusChildLocked(c) st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) st.updateActiveAgentLocked(c)
renderer := newViewportRenderer(layout) renderer := newViewportRenderer(layout)
renderer.SetChildOnAlt(onAlt) renderer.SetChildOnAlt(onAlt)
@@ -929,10 +899,10 @@ func (st *uiState) OnChildExited(c *Child) {
if next == nil { if next == nil {
st.focusedID = "" st.focusedID = ""
st.focusedName = "" st.focusedName = ""
st.view.FocusedID = ""
renderEmpty = true renderEmpty = true
} else { } else {
st.focusChildLocked(next) st.focusedID = next.ID
st.focusedName = next.DisplayName()
st.updateActiveAgentLocked(next) st.updateActiveAgentLocked(next)
st.renderer = newViewportRenderer(layout) st.renderer = newViewportRenderer(layout)
} }
@@ -941,7 +911,6 @@ func (st *uiState) OnChildExited(c *Child) {
// The active agent died; pin the agent tree to whatever agent // The active agent died; pin the agent tree to whatever agent
// root is still running, or clear it if none remain. // root is still running, or clear it if none remain.
st.activeAgentID = firstRunningAgentID(st.sess.Children()) st.activeAgentID = firstRunningAgentID(st.sess.Children())
st.view.ActiveAgentID = st.activeAgentID
} }
if st.palette != nil { if st.palette != nil {
st.palette.children = st.sess.Children() st.palette.children = st.sess.Children()
@@ -1418,11 +1387,8 @@ func (st *uiState) renderEmptyState() {
func (st *uiState) hostSizeSnapshot() (uint16, uint16) { func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
st.dimsMu.Lock() st.dimsMu.Lock()
defer st.dimsMu.Unlock() defer st.dimsMu.Unlock()
if st.view.Cols == 0 || st.view.Rows == 0 {
return st.hostCols, st.hostRows return st.hostCols, st.hostRows
} }
return st.view.Cols, st.view.Rows
}
func (st *uiState) layoutSnapshot() terminalLayout { func (st *uiState) layoutSnapshot() terminalLayout {
st.dimsMu.Lock() st.dimsMu.Lock()
@@ -1431,11 +1397,8 @@ func (st *uiState) layoutSnapshot() terminalLayout {
} }
func (st *uiState) layoutLocked() terminalLayout { func (st *uiState) layoutLocked() terminalLayout {
if st.view.Cols == 0 || st.view.Rows == 0 {
return newTerminalLayout(st.hostCols, st.hostRows) return newTerminalLayout(st.hostCols, st.hostRows)
} }
return newTerminalLayout(st.view.Cols, st.view.Rows)
}
// splitOnEnter walks input and returns each Enter byte (CR or LF) as // splitOnEnter walks input and returns each Enter byte (CR or LF) as
// its own slice, with the surrounding non-Enter bytes batched between. // its own slice, with the surrounding non-Enter bytes batched between.
@@ -2123,7 +2086,9 @@ func (st *uiState) closePalette(action paletteAction) {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.mu.Lock() st.mu.Lock()
leavingPad := st.focusedPad != "" leavingPad := st.focusedPad != ""
st.focusChildLocked(c) st.focusedPad = ""
st.focusedID = action.childID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c) st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout) st.renderer = newViewportRenderer(layout)
st.mu.Unlock() st.mu.Unlock()
@@ -2267,8 +2232,13 @@ func (st *uiState) handlePadDelete(name string) {
if entries := st.padsList(); len(entries) > 0 { if entries := st.padsList(); len(entries) > 0 {
next := entries[0].Name next := entries[0].Name
st.mu.Lock() st.mu.Lock()
st.focusPadLocked(next) st.focusedPad = next
st.focusedID = ""
st.focusedName = next st.focusedName = next
if st.padOffsetName != next {
st.padOffset = 0
st.padOffsetName = next
}
st.mu.Unlock() st.mu.Unlock()
st.repaintFocusedWithChrome() st.repaintFocusedWithChrome()
return return
@@ -2279,12 +2249,9 @@ func (st *uiState) handlePadDelete(name string) {
} }
st.mu.Lock() st.mu.Lock()
st.focusedPad = "" st.focusedPad = ""
st.view.FocusedPad = ""
st.focusedName = "" st.focusedName = ""
st.padOffset = 0 st.padOffset = 0
st.padOffsetName = "" st.padOffsetName = ""
st.view.PadOffset = 0
st.view.PadOffsetName = ""
st.mu.Unlock() st.mu.Unlock()
st.renderEmptyState() st.renderEmptyState()
st.drawTabBar() st.drawTabBar()
@@ -2311,7 +2278,7 @@ func (st *uiState) handlePadRename(oldName, newName string) {
} }
st.mu.Lock() st.mu.Lock()
if st.focusedPad == oldName { if st.focusedPad == oldName {
st.focusPadLocked(newName) st.focusedPad = newName
} }
st.mu.Unlock() st.mu.Unlock()
st.scratchpadsChanged() st.scratchpadsChanged()
@@ -2582,7 +2549,6 @@ func (st *uiState) renderPadView(name, content string, layout terminalLayout) []
st.padOffset = 0 st.padOffset = 0
} }
offset := st.padOffset offset := st.padOffset
st.view.PadOffset = offset
st.mu.Unlock() st.mu.Unlock()
var b strings.Builder var b strings.Builder
@@ -2640,7 +2606,6 @@ func (st *uiState) exitPadView() {
return return
} }
st.focusedPad = "" st.focusedPad = ""
st.view.FocusedPad = ""
st.focusedName = "" st.focusedName = ""
st.mu.Unlock() st.mu.Unlock()
st.clearViewportArea() st.clearViewportArea()
@@ -2667,7 +2632,6 @@ func (st *uiState) padScroll(delta int) {
if st.padOffset < 0 { if st.padOffset < 0 {
st.padOffset = 0 st.padOffset = 0
} }
st.view.PadOffset = st.padOffset
st.mu.Unlock() st.mu.Unlock()
st.repaintFocusedPad() st.repaintFocusedPad()
} }

143
internal/app/canonical.go Normal file
View 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)
}

View 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)
}
}

View File

@@ -228,7 +228,7 @@ func (c *Child) startPTY(cols, rows uint16) (uint64, error) {
} }
starting := StatusStarting starting := StatusStarting
c.status.Store(&starting) c.status.Store(&starting)
p, err := pkgpty.Start(c.Argv, c.Env, c.WorkDir, cols, rows) p, err := pkgpty.Start(c.Argv, c.Env, cols, rows)
if err != nil { if err != nil {
em.Close() em.Close()
errored := StatusErrored errored := StatusErrored
@@ -532,6 +532,12 @@ func (c *Child) StreamRead(since int64) ([]byte, int64) {
return out, end 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 { func (c *Child) signal(sig syscall.Signal) error {
pty := c.PTY() pty := c.PTY()
if pty == nil { if pty == nil {

View File

@@ -1,78 +0,0 @@
package app
import "github.com/hjbdev/patterm/internal/scratchpad"
// chromeModel is the semantic host chrome state. Renderers continue to own
// ANSI output; this model is the serializable shape a client can draw locally.
type chromeModel struct {
ProjectKey string `json:"project_key"`
FocusedID string `json:"focused_id,omitempty"`
FocusedPad string `json:"focused_pad,omitempty"`
ActiveAgentID string `json:"active_agent_id,omitempty"`
Tabs []childModel `json:"tabs"`
Processes []childModel `json:"processes"`
AgentTree []childModel `json:"agent_tree"`
Sidebar []navEntryModel `json:"sidebar"`
Scratchpads []scratchpadModel `json:"scratchpads"`
}
type childModel struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
ParentID string `json:"parent_id,omitempty"`
Status string `json:"status"`
Owner string `json:"owner"`
}
type navEntryModel struct {
ChildID string `json:"child_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type scratchpadModel struct {
Name string `json:"name"`
}
func buildChromeModel(projectKey string, view ClientView, children []*Child, pads []scratchpad.Entry) chromeModel {
active := view.ActiveAgentID
if active == "" {
active = activeRootID(children, view.FocusedID)
}
model := chromeModel{
ProjectKey: projectKey,
FocusedID: view.FocusedID,
FocusedPad: view.FocusedPad,
ActiveAgentID: active,
}
for _, c := range runningTopLevels(children) {
model.Tabs = append(model.Tabs, serializeChildModel(c))
}
for _, c := range processList(children) {
model.Processes = append(model.Processes, serializeChildModel(c))
}
for _, c := range visibleAgentTree(children, active) {
model.AgentTree = append(model.AgentTree, serializeChildModel(c))
}
for _, n := range sidebarNav(children, active, pads) {
model.Sidebar = append(model.Sidebar, navEntryModel{ChildID: n.childID, Pad: n.pad})
}
for _, p := range pads {
model.Scratchpads = append(model.Scratchpads, scratchpadModel{Name: p.Name})
}
return model
}
func serializeChildModel(c *Child) childModel {
if c == nil {
return childModel{}
}
return childModel{
ID: c.ID,
Name: c.DisplayName(),
Kind: string(c.Kind),
ParentID: c.ParentID,
Status: string(c.Status()),
Owner: string(c.Owner()),
}
}

View File

@@ -1,24 +0,0 @@
package app
import "testing"
func TestBuildChromeModelSeparatesProcessesTabsAndSidebar(t *testing.T) {
running := StatusRunning
proc := testProcess("p1", "server", running)
agent := testAgent("a1", "codex", "", running)
sub := testAgent("a2", "worker", "a1", running)
model := buildChromeModel("project", ClientView{FocusedID: "p1", ActiveAgentID: "a1"}, []*Child{proc, agent, sub}, nil)
if len(model.Tabs) != 1 || model.Tabs[0].ID != "a1" {
t.Fatalf("tabs = %#v, want only top-level agent", model.Tabs)
}
if len(model.Processes) != 1 || model.Processes[0].ID != "p1" {
t.Fatalf("processes = %#v, want process section", model.Processes)
}
if len(model.AgentTree) != 2 || model.AgentTree[0].ID != "a1" || model.AgentTree[1].ID != "a2" {
t.Fatalf("agent tree = %#v", model.AgentTree)
}
if len(model.Sidebar) != 3 || model.Sidebar[0].ChildID != "p1" || model.Sidebar[1].ChildID != "a1" {
t.Fatalf("sidebar = %#v", model.Sidebar)
}
}

View File

@@ -1,122 +0,0 @@
package app
import (
"encoding/json"
"sync"
"github.com/hjbdev/patterm/internal/protocol"
)
const defaultClientSubscriberQueue = 256
// clientSubscriber is the daemon-to-client event bridge. Unlike daemon-local
// listeners such as timers, debug capture, and waiters, it never blocks the PTY
// pump: PTY chunks are copied before enqueue, and overflow marks the pane as
// needing a fresh snapshot.
type clientSubscriber struct {
projectKey string
frames chan protocol.Frame
mu sync.Mutex
snapshotRequired map[string]bool
lifecycleDirty bool
}
func newClientSubscriber(projectKey string, size int) *clientSubscriber {
if size <= 0 {
size = defaultClientSubscriberQueue
}
return &clientSubscriber{
projectKey: projectKey,
frames: make(chan protocol.Frame, size),
snapshotRequired: make(map[string]bool),
lifecycleDirty: false,
}
}
func (s *clientSubscriber) Recv() (protocol.Frame, bool) {
f, ok := <-s.frames
return f, ok
}
func (s *clientSubscriber) SnapshotRequired(childID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotRequired[childID]
}
func (s *clientSubscriber) OnChildSpawned(c *Child) {
s.sendLifecycle(protocol.LifecycleSpawned, c, "")
}
func (s *clientSubscriber) OnChildExited(c *Child) {
s.sendLifecycle(protocol.LifecycleExited, c, "")
}
func (s *clientSubscriber) OnChildClosed(id string) {
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: protocol.LifecycleClosed,
ProjectKey: s.projectKey,
ChildID: id,
})})
}
func (s *clientSubscriber) OnChildStateChanged(id string, state IdleState) {
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: protocol.LifecycleStateChanged,
ProjectKey: s.projectKey,
ChildID: id,
State: string(state),
})})
}
func (s *clientSubscriber) OnPTYOut(childID string, chunk []byte) {
cp := append([]byte(nil), chunk...)
f, err := protocol.NewFrame(protocol.FramePaneChunk, protocol.PaneChunk{PaneID: childID, Bytes: cp})
if err != nil {
return
}
select {
case s.frames <- f:
default:
s.mu.Lock()
s.snapshotRequired[childID] = true
s.mu.Unlock()
}
}
func (s *clientSubscriber) sendLifecycle(kind protocol.LifecycleKind, c *Child, state string) {
var child json.RawMessage
if c != nil {
child = mustJSON(serializeChildModel(c))
}
childID := ""
if c != nil {
childID = c.ID
}
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: kind,
ProjectKey: s.projectKey,
ChildID: childID,
Child: child,
State: state,
})})
}
func (s *clientSubscriber) sendFrame(f protocol.Frame) {
select {
case s.frames <- f:
default:
s.mu.Lock()
s.lifecycleDirty = true
s.mu.Unlock()
}
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}

View File

@@ -1,32 +0,0 @@
package app
import (
"testing"
"github.com/hjbdev/patterm/internal/protocol"
)
func TestClientSubscriberCopiesChunksAndMarksSnapshotOnOverflow(t *testing.T) {
sub := newClientSubscriber("project", 1)
chunk := []byte("first")
sub.OnPTYOut("p_123456", chunk)
chunk[0] = 'X'
f, ok := sub.Recv()
if !ok {
t.Fatalf("Recv closed")
}
payload, err := protocol.Decode[protocol.PaneChunk](f)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if string(payload.Bytes) != "first" {
t.Fatalf("payload retained pump buffer: %q", string(payload.Bytes))
}
sub.OnPTYOut("p_123456", []byte("queued"))
sub.OnPTYOut("p_123456", []byte("dropped"))
if !sub.SnapshotRequired("p_123456") {
t.Fatalf("overflow did not mark pane snapshot required")
}
}

View File

@@ -1,39 +0,0 @@
package app
// ClientView is the per-client UI cursor over daemon-owned project/process
// state. In loopback mode there is one view, owned by uiState; future network
// clients will each get their own copy.
type ClientView struct {
ID string
ProjectKey string
FocusedID string
FocusedPad string
ActiveAgentID string
PadOffset int
PadOffsetName string
Cols uint16
Rows uint16
}
func (v *ClientView) FocusChild(id string) {
v.FocusedID = id
v.FocusedPad = ""
}
func (v *ClientView) FocusPad(name string) {
v.FocusedID = ""
v.FocusedPad = name
if v.PadOffsetName != name {
v.PadOffset = 0
v.PadOffsetName = name
}
}
func (v *ClientView) ClearPadFocus() {
v.FocusedPad = ""
}
func (v *ClientView) Resize(cols, rows uint16) {
v.Cols = cols
v.Rows = rows
}

View File

@@ -1,29 +0,0 @@
package app
import (
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
)
// headlessCore is the daemon-owned half of today's single-process app. It is
// intentionally small for the foundation phase: it groups process/project
// state while the existing loopback client still renders in-process.
type headlessCore struct {
projectDir string
projectKey string
presets preset.Set
settings settings
pads *scratchpad.Store
trustStore *trust.Store
persistStore *persist.Store
mcpSrv *mcp.Server
sess *Session
launcher *Launcher
host *toolHost
}

View File

@@ -65,6 +65,17 @@ type toolHost struct {
timers *timerManager 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 { func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
h := &toolHost{ h := &toolHost{
sess: sess, sess: sess,
@@ -353,8 +364,8 @@ func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStat
return st, nil return st, nil
} }
func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) { func (h *toolHost) GetProjectStatus(callerID string, includeTools bool) (mcp.ProjectStatus, error) {
caller := h.WhoAmI(callerID) caller := h.WhoAmI(callerID, includeTools)
processes := h.ListProcesses(callerID, "") processes := h.ListProcesses(callerID, "")
pads, _ := h.pads.List() pads, _ := h.pads.List()
return mcp.ProjectStatus{ return mcp.ProjectStatus{
@@ -365,27 +376,48 @@ func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error)
}, nil }, 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) c := h.sess.FindChild(processID)
if c == nil { if c == nil {
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) 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{ out := mcp.ProcessOutput{
Mode: mode, Mode: mode,
IdleMS: c.IdleMS(),
Status: string(c.Status()), Status: string(c.Status()),
ScreenVersion: c.ScreenVersion(), Canonicalized: true,
} }
if args.IncludeMeta {
out.IdleMS = c.IdleMS()
out.ScreenVersion = c.ScreenVersion()
if em := c.Emulator(); em != nil { if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil { if sc, err := em.ActiveScreen(); err == nil {
out.ActiveScreen = activeScreenName(sc) out.ActiveScreen = activeScreenName(sc)
} }
if cur, err := em.Cursor(); err == nil { if cur, err := em.Cursor(); err == nil {
out.Cursor = mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)} out.Cursor = &mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
} }
cols, rows := em.Size() cols, rows := em.Size()
out.Cols, out.Rows = int(cols), int(rows) out.Cols, out.Rows = int(cols), int(rows)
} }
}
maxLines := canonicalLineLimit(args.MaxLines)
switch mode { switch mode {
case "grid": case "grid":
em := c.Emulator() em := c.Emulator()
@@ -399,11 +431,21 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
if c.Kind == KindAgent { if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef)) 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 return out, nil
case "stream": case "stream":
b, end := c.StreamRead(sinceOffset) 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 out.NewOffset = end
return out, nil return out, nil
default: 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) { func (h *toolHost) GetProcessRawOutput(callerID string, args mcp.RawOutputArgs) (mcp.RawOutput, error) {
c := h.sess.FindChild(processID) c := h.sess.FindChild(args.ProcessID)
if c == nil { 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{ return mcp.RawOutput{
Content: string(b), Content: content,
NewOffset: end, NewOffset: end,
Status: string(c.Status()), Status: string(c.Status()),
ContentBytes: contentBytes,
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil }, nil
} }
func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) { func (h *toolHost) SearchOutput(callerID string, args mcp.SearchOutputArgs) (mcp.SearchResult, error) {
c := h.sess.FindChild(processID) c := h.sess.FindChild(args.ProcessID)
if c == nil { 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 { if err != nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err) return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
} }
b, _ := c.StreamRead(0) b, _ := c.StreamRead(0)
if kind == "rendered" { if args.Kind == "rendered" {
b = stripANSIBytes(nil, b) b = stripANSIBytes(nil, b)
} }
text := string(b) text := string(b)
lines := strings.Split(text, "\n") 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) matches := make([]mcp.SearchMatch, 0, limit)
truncated := false truncated := false
for i, line := range lines { for i, line := range lines {
@@ -447,6 +501,8 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
truncated = true truncated = true
break break
} }
line, _, lineTruncated, _ := capTextTail(line, lineLimit)
truncated = truncated || lineTruncated
matches = append(matches, mcp.SearchMatch{LineNo: i + 1, Text: line}) 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 { if err != nil {
return mcp.SendInputResult{}, err return mcp.SendInputResult{}, err
} }
tailSince := c.StreamOffset()
if err := c.InjectAsOrchestrator(payload); err != nil { if err := c.InjectAsOrchestrator(payload); err != nil {
return mcp.SendInputResult{}, err return mcp.SendInputResult{}, err
} }
@@ -599,7 +656,12 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
} }
if mode != "none" { if mode != "none" {
time.Sleep(time.Duration(args.WaitMS) * time.Millisecond) 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 { if err == nil {
res.Tail = &tail 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) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadRead(name string) (string, string, error) { func (h *toolHost) ScratchpadRead(args mcp.ScratchpadReadArgs) (mcp.ScratchpadReadResult, error) {
return h.pads.Read(name) 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) { func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
@@ -841,7 +925,7 @@ func (h *toolHost) ScratchpadDelete(name string) error {
return err return err
} }
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI { func (h *toolHost) WhoAmI(callerID string, includeTools bool) mcp.WhoAmI {
w := mcp.WhoAmI{ w := mcp.WhoAmI{
ProcessID: callerID, ProcessID: callerID,
Role: h.CallerRole(callerID), Role: h.CallerRole(callerID),
@@ -849,7 +933,9 @@ func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
Path: h.sess.projectDir, Path: h.sess.projectDir,
Key: h.sess.projectKey, 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 { if c := h.sess.FindChild(callerID); c != nil {
w.Name = c.DisplayName() w.Name = c.DisplayName()
@@ -1009,11 +1095,10 @@ func activeScreenName(s pkgvt.Screen) string {
} }
} }
// ansiRegexp strips CSI escape sequences and common single-character // ansiRegexp strips CSI/OSC escape sequences and common single-character
// controls (BEL, OSC terminators) from the stream. The vt emulator // controls from the stream. The vt emulator already handles full
// already handles full rendering for grid mode; this is only for // rendering for grid mode; this is only for stream-mode text output.
// stream-mode ANSI-stripped output. var ansiRegexp = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
var ansiRegexp = regexp.MustCompile(`\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
func stripANSI(s string) string { func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "") return ansiRegexp.ReplaceAllString(s, "")
@@ -1043,12 +1128,68 @@ func normalizeGridText(s string) string {
return strings.Join(out, "\n") 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 // stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will // string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a // itself walk the result line-by-line (SearchOutput) or feed it to a
// pattern match (WaitForPattern scrollback). Recognises the same // pattern match (WaitForPattern scrollback). Recognises the same
// shapes the regex did: // shapes the regex did:
// - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR) // - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR)
// - `\x1b] ... (BEL|ST)` (OSC)
// - `\x1b<final-byte>` for `@..._` (one-byte escapes) // - `\x1b<final-byte>` for `@..._` (one-byte escapes)
// - `\x07` (BEL) // - `\x07` (BEL)
// //
@@ -1078,6 +1219,24 @@ func stripANSIBytes(dst, src []byte) []byte {
continue continue
} }
next := src[i+1] 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 != '[' { if next != '[' {
// One-byte ESC sequence (`\x1b<final>` where final is // One-byte ESC sequence (`\x1b<final>` where final is
// `@..._` per the regex; we drop anything that follows). // `@..._` per the regex; we drop anything that follows).
@@ -1160,7 +1319,7 @@ func helpFor(topic string) mcp.HelpResponse {
case "inspection": case "inspection":
return mcp.HelpResponse{ return mcp.HelpResponse{
Topic: "inspection", 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"}, RelatedTools: []string{"list_processes", "get_process_status", "get_process_output", "search_output", "wait_for_pattern", "get_project_status"},
} }
case "io": case "io":

View File

@@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/scratchpad"
) )
// mkChild builds a Child without starting a PTY. Use sparingly — the // 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) { func TestHelpLifecycleTopicCoversCleanup(t *testing.T) {
resp := helpFor("lifecycle") resp := helpFor("lifecycle")
if resp.Topic != "lifecycle" { if resp.Topic != "lifecycle" {

View File

@@ -90,6 +90,8 @@ func TestStripANSIBytesEquivalence(t *testing.T) {
cases := []string{ cases := []string{
"hello world", "hello world",
"\x1b[31mred\x1b[0m text", "\x1b[31mred\x1b[0m text",
"\x1b]0;title\x07after osc",
"\x1b]2;title\x1b\\after st",
"line1\nline2\r\nline3", "line1\nline2\r\nline3",
"bell\x07ish", "bell\x07ish",
"weird \x1bA escape", "weird \x1bA escape",

View File

@@ -46,13 +46,6 @@ type Session struct {
listenersMu sync.Mutex listenersMu sync.Mutex
listeners atomic.Pointer[[]ChildEventListener] listeners atomic.Pointer[[]ChildEventListener]
// clientListeners is the network-client subscriber path. These
// listeners must be non-blocking and copy PTY chunks before enqueueing;
// daemon-internal observers (timers, debug capture, waiters) stay on
// listeners above so backpressure policy is isolated to clients.
clientListenersMu sync.Mutex
clientListeners atomic.Pointer[[]ChildEventListener]
// persistStore records top-level command entries to a per-project // persistStore records top-level command entries to a per-project
// JSON file so they can be re-spawned after patterm restarts. // JSON file so they can be re-spawned after patterm restarts.
// Optional; nil means "no persistence" (used by unit tests). // Optional; nil means "no persistence" (used by unit tests).
@@ -125,16 +118,6 @@ func (s *Session) Subscribe(l ChildEventListener) {
s.listeners.Store(&next) s.listeners.Store(&next)
} }
func (s *Session) SubscribeClient(l ChildEventListener) {
s.clientListenersMu.Lock()
defer s.clientListenersMu.Unlock()
prev := s.clientListenersSnapshot()
next := make([]ChildEventListener, 0, len(prev)+1)
next = append(next, prev...)
next = append(next, l)
s.clientListeners.Store(&next)
}
// Unsubscribe removes a previously-registered listener. Safe to call // Unsubscribe removes a previously-registered listener. Safe to call
// with a listener that wasn't registered (no-op). // with a listener that wasn't registered (no-op).
func (s *Session) Unsubscribe(l ChildEventListener) { func (s *Session) Unsubscribe(l ChildEventListener) {
@@ -163,30 +146,16 @@ func (s *Session) listenersSnapshot() []ChildEventListener {
return *p return *p
} }
func (s *Session) clientListenersSnapshot() []ChildEventListener {
p := s.clientListeners.Load()
if p == nil {
return nil
}
return *p
}
func (s *Session) emitSpawn(c *Child) { func (s *Session) emitSpawn(c *Child) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnChildSpawned(c) l.OnChildSpawned(c)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnChildSpawned(c)
}
} }
func (s *Session) emitExit(c *Child) { func (s *Session) emitExit(c *Child) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnChildExited(c) l.OnChildExited(c)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnChildExited(c)
}
} }
// emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners // emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners
@@ -196,27 +165,18 @@ func (s *Session) emitPTYOut(id string, chunk []byte) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnPTYOut(id, chunk) l.OnPTYOut(id, chunk)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnPTYOut(id, chunk)
}
} }
func (s *Session) emitStateChanged(id string, state IdleState) { func (s *Session) emitStateChanged(id string, state IdleState) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnChildStateChanged(id, state) l.OnChildStateChanged(id, state)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnChildStateChanged(id, state)
}
} }
func (s *Session) emitClosed(id string) { func (s *Session) emitClosed(id string) {
for _, l := range s.listenersSnapshot() { for _, l := range s.listenersSnapshot() {
l.OnChildClosed(id) l.OnChildClosed(id)
} }
for _, l := range s.clientListenersSnapshot() {
l.OnChildClosed(id)
}
} }
func (s *Session) ChildEnv() []string { func (s *Session) ChildEnv() []string {

View File

@@ -561,10 +561,12 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
if t.status != timerStatusPending && t.status != timerStatusPaused { if t.status != timerStatusPending && t.status != timerStatusPaused {
continue continue
} }
body, bodyTruncated := timerBodyPreview(t.body)
info := mcp.TimerInfo{ info := mcp.TimerInfo{
ID: t.id, ID: t.id,
Label: t.label, Label: t.label,
Body: t.body, Body: body,
BodyTruncated: bodyTruncated,
Kind: string(t.kind), Kind: string(t.kind),
Status: t.status, Status: t.status,
OwnerID: t.ownerID, OwnerID: t.ownerID,
@@ -581,6 +583,14 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
return out 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 // activeForChild returns the nearest pending or paused timer attached
// to child id (either owned by it or watching it). Used by the sidebar // to child id (either owned by it or watching it). Used by the sidebar
// for the "⏱ 12s" indicator. nil when none. // for the "⏱ 12s" indicator. nil when none.

View File

@@ -143,7 +143,7 @@ func openSession(t *testing.T, env *testEnv, childEnv []string) *Session {
if err != nil { if err != nil {
t.Fatalf("vt emulator: %v", err) t.Fatalf("vt emulator: %v", err)
} }
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows)
if err != nil { if err != nil {
_ = em.Close() _ = em.Close()
t.Fatalf("pty start: %v", err) t.Fatalf("pty start: %v", err)

View 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
}
]
}

View File

@@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows)
if err != nil { if err != nil {
_ = em.Close() _ = em.Close()
return nil, err return nil, err

View File

@@ -134,16 +134,16 @@ func (h *blockingToolHost) ListProcesses(string, string) []ProcessInfo { return
func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) { func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) {
return ProcessStatus{ProcessInfo: ProcessInfo{ID: "p_fast", Status: "running"}}, nil 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 return ProjectStatus{}, nil
} }
func (h *blockingToolHost) GetProcessOutput(string, string, string, int64) (ProcessOutput, error) { func (h *blockingToolHost) GetProcessOutput(string, ProcessOutputArgs) (ProcessOutput, error) {
return ProcessOutput{}, nil return ProcessOutput{}, nil
} }
func (h *blockingToolHost) GetProcessRawOutput(string, string, int64) (RawOutput, error) { func (h *blockingToolHost) GetProcessRawOutput(string, RawOutputArgs) (RawOutput, error) {
return RawOutput{}, nil 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 return SearchResult{}, nil
} }
func (h *blockingToolHost) WaitForPattern(string, string, string, float64, string) (bool, string, error) { 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 return nil, nil
} }
func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil } func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil }
func (h *blockingToolHost) ScratchpadRead(string) (string, string, error) { func (h *blockingToolHost) ScratchpadRead(ScratchpadReadArgs) (ScratchpadReadResult, error) {
return "", "", nil return ScratchpadReadResult{}, nil
} }
func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) { func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) {
return "", nil return "", nil
} }
func (h *blockingToolHost) ScratchpadAppend(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) 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{} } func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }

View File

@@ -3,6 +3,8 @@ package mcp
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/hjbdev/patterm/internal/scratchpad"
) )
// MCP protocol surface. The patterm server originally exposed each // 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. // 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. // 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 // toolDescriptor is the shape returned by `tools/list`. inputSchema is
// a JSON Schema object — we provide a minimal `{type: "object"}` schema // a JSON Schema object — we provide a minimal `{type: "object"}` schema
@@ -76,25 +78,29 @@ func objectSchema(properties map[string]any, required []string) map[string]any {
} }
func stringProp(desc 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 { 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 { 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 { 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 { func arrayOfStringsProp(desc string) map[string]any {
_ = desc
return map[string]any{ return map[string]any{
"type": "array", "type": "array",
"description": desc,
"items": map[string]any{"type": "string"}, "items": map[string]any{"type": "string"},
} }
} }
@@ -102,11 +108,11 @@ func arrayOfStringsProp(desc string) map[string]any {
// toolCatalog is the full list advertised via tools/list. Descriptions // toolCatalog is the full list advertised via tools/list. Descriptions
// are intentionally short — clients are expected to fetch help() for // are intentionally short — clients are expected to fetch help() for
// detail. Schemas mirror the param structs in tools.go. // detail. Schemas mirror the param structs in tools.go.
func toolCatalog() []toolDescriptor { func toolCatalog(role CallerRole) []toolDescriptor {
return []toolDescriptor{ tools := []toolDescriptor{
{ {
Name: "spawn_agent", 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{ InputSchema: objectSchema(map[string]any{
"agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."), "agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."),
"agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."), "agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."),
@@ -115,14 +121,14 @@ func toolCatalog() []toolDescriptor {
}, },
{ {
Name: "spawn_process", 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{ InputSchema: objectSchema(map[string]any{
"kind": stringProp("\"terminal\" or \"command\"."), "kind": stringProp("\"terminal\" or \"command\"."),
"preset": stringProp("Process preset name (mutually exclusive with argv)."), "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."), "name": stringProp("Display name for the pane."),
"working_dir": stringProp("Working directory for the spawned process."), "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."), "shell": booleanProp("Run argv through sh -lc."),
}, nil), }, nil),
}, },
@@ -188,23 +194,30 @@ func toolCatalog() []toolDescriptor {
{ {
Name: "get_project_status", Name: "get_project_status",
Description: "One-shot orientation: project, caller, processes, scratchpads.", 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", 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{ InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."), "process_id": stringProp("Target process id."),
"mode": stringProp("\"grid\" (default) or \"stream\"."), "mode": stringProp("\"grid\" (default) or \"stream\"."),
"since_offset": integerProp("Watermark offset from a previous call."), "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"}), }, []string{"process_id"}),
}, },
{ {
Name: "get_process_raw_output", 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{ InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."), "process_id": stringProp("Target process id."),
"since_offset": integerProp("Byte offset from a previous call."), "since_offset": integerProp("Byte offset from a previous call."),
"max_bytes": integerProp("Maximum content bytes to return."),
}, []string{"process_id"}), }, []string{"process_id"}),
}, },
{ {
@@ -214,12 +227,13 @@ func toolCatalog() []toolDescriptor {
"process_id": stringProp("Target process id."), "process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."), "pattern": stringProp("Regex pattern."),
"kind": stringProp("\"rendered\" (default) or \"raw\"."), "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"}), }, []string{"process_id", "pattern"}),
}, },
{ {
Name: "wait_for_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{ InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."), "process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."), "pattern": stringProp("Regex pattern."),
@@ -245,11 +259,12 @@ func toolCatalog() []toolDescriptor {
"submit": booleanProp("Whether to append a submit keystroke."), "submit": booleanProp("Whether to append a submit keystroke."),
"wait_ms": integerProp("After sending, wait this many ms before tailing."), "wait_ms": integerProp("After sending, wait this many ms before tailing."),
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."), "tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
"tail_max_bytes": integerProp("Maximum bytes in returned tail."),
}, []string{"process_id", "kind"}), }, []string{"process_id", "kind"}),
}, },
{ {
Name: "send_message", 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{ InputSchema: objectSchema(map[string]any{
"target_process_id": stringProp("Recipient process id."), "target_process_id": stringProp("Recipient process id."),
"message": stringProp("Message body."), "message": stringProp("Message body."),
@@ -283,7 +298,7 @@ func toolCatalog() []toolDescriptor {
}, },
{ {
Name: "timer_fire_when_idle_any", 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{ InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."), "watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."), "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", 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{ InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."), "watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."), "body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
@@ -339,6 +354,8 @@ func toolCatalog() []toolDescriptor {
Description: "Read a scratchpad entry, returning content and revision.", Description: "Read a scratchpad entry, returning content and revision.",
InputSchema: objectSchema(map[string]any{ 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"}), }, []string{"name"}),
}, },
{ {
@@ -367,8 +384,10 @@ func toolCatalog() []toolDescriptor {
}, },
{ {
Name: "whoami", Name: "whoami",
Description: "Return the caller's identity, role, parent, project metadata, and available tools.", Description: "Return caller identity, role, parent, and project metadata.",
InputSchema: objectSchema(nil, nil), InputSchema: objectSchema(map[string]any{
"include_tools": booleanProp("Include full available tool list."),
}, nil),
}, },
{ {
Name: "help", Name: "help",
@@ -378,6 +397,16 @@ func toolCatalog() []toolDescriptor {
}, nil), }, 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 // 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 return map[string]any{}, true, 0, "", nil
case "tools/list": 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": case "tools/call":
var p struct { var p struct {
@@ -472,25 +508,12 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
return nil, false, 0, "", nil return nil, false, 0, "", nil
} }
// wrapToolResult turns a structured tool result into an MCP tools/call // wrapToolResult turns a tool result into an MCP tools/call response.
// response. Plain strings (e.g. "ok") become text content; structured // Structured values are exposed once under structuredContent; content
// values are JSON-encoded into a single text block and also exposed // carries only a short model-readable summary to avoid duplicating
// under structuredContent so capable clients can read the shape. // large JSON payloads into the transcript.
func wrapToolResult(result any) map[string]any { func wrapToolResult(result any) map[string]any {
var text string text := summarizeToolResult(result)
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)
}
}
out := map[string]any{ out := map[string]any{
"content": []map[string]any{{"type": "text", "text": text}}, "content": []map[string]any{{"type": "text", "text": text}},
"isError": false, "isError": false,
@@ -505,3 +528,70 @@ func wrapToolResult(result any) map[string]any {
} }
return out 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
}

View File

@@ -2,6 +2,7 @@ package mcp
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
) )
@@ -43,6 +44,9 @@ func TestInitializeReturnsCapabilities(t *testing.T) {
if !ok || instructions == "" { if !ok || instructions == "" {
t.Fatalf("instructions missing or wrong type: %+v", parsed.Result) 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) { func TestInitializedNotificationSuppressesResponse(t *testing.T) {
@@ -74,6 +78,9 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
if parsed.Error != nil { if parsed.Error != nil {
t.Fatalf("tools/list returned error: %+v", parsed.Error) 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{}) tools, ok := parsed.Result["tools"].([]interface{})
if !ok { if !ok {
t.Fatalf("tools not array: %+v", parsed.Result) 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) { func TestPingReturnsEmptyObject(t *testing.T) {
s := &Server{} s := &Server{}
req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`) req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`)

View File

@@ -74,10 +74,10 @@ type ToolHost interface {
// Inspection. // Inspection.
ListProcesses(callerID, kindFilter string) []ProcessInfo ListProcesses(callerID, kindFilter string) []ProcessInfo
GetProcessStatus(callerID, processID string) (ProcessStatus, error) GetProcessStatus(callerID, processID string) (ProcessStatus, error)
GetProjectStatus(callerID string) (ProjectStatus, error) GetProjectStatus(callerID string, includeTools bool) (ProjectStatus, error)
GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (ProcessOutput, error) GetProcessOutput(callerID string, args ProcessOutputArgs) (ProcessOutput, error)
GetProcessRawOutput(callerID, processID string, sinceOffset int64) (RawOutput, error) GetProcessRawOutput(callerID string, args RawOutputArgs) (RawOutput, error)
SearchOutput(callerID, processID, pattern, kind string, limit int) (SearchResult, error) SearchOutput(callerID string, args SearchOutputArgs) (SearchResult, error)
WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error)
GetProcessPorts(callerID, processID string) ([]PortSighting, error) GetProcessPorts(callerID, processID string) ([]PortSighting, error)
@@ -98,13 +98,13 @@ type ToolHost interface {
// Scratchpads. // Scratchpads.
ScratchpadList() ([]scratchpad.Entry, error) 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) ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error ScratchpadAppend(name, content string) error
ScratchpadDelete(name string) error ScratchpadDelete(name string) error
// Meta. // Meta.
WhoAmI(callerID string) WhoAmI WhoAmI(callerID string, includeTools bool) WhoAmI
Help(callerID, topic string) HelpResponse Help(callerID, topic string) HelpResponse
} }
@@ -157,14 +157,19 @@ type ProjectStatus struct {
Scratchpads []scratchpad.Entry `json:"scratchpads"` Scratchpads []scratchpad.Entry `json:"scratchpads"`
} }
type ProjectStatusArgs struct {
IncludeTools bool `json:"include_tools"`
}
// ProjectMeta is the project root info echoed in many payloads. // ProjectMeta is the project root info echoed in many payloads.
type ProjectMeta struct { type ProjectMeta struct {
Path string `json:"path"` Path string `json:"path"`
Key string `json:"key"` Key string `json:"key"`
} }
// ProcessOutput is the get_process_output payload. SPEC §7 enriches // ProcessOutput is the get_process_output payload. By default it is
// the old read_output result with screen geometry + version. // canonical text with light metadata; include_meta restores screen
// geometry + version, and raw requests return stream bytes.
type ProcessOutput struct { type ProcessOutput struct {
Content string `json:"content"` Content string `json:"content"`
Mode string `json:"mode"` Mode string `json:"mode"`
@@ -172,10 +177,24 @@ type ProcessOutput struct {
ActiveScreen string `json:"active_screen,omitempty"` ActiveScreen string `json:"active_screen,omitempty"`
Rows int `json:"rows,omitempty"` Rows int `json:"rows,omitempty"`
Cols int `json:"cols,omitempty"` Cols int `json:"cols,omitempty"`
Cursor Cursor `json:"cursor"` Cursor *Cursor `json:"cursor,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"` IdleMS int64 `json:"idle_ms,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
ScreenVersion int64 `json:"screen_version,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. // RawOutput is the get_process_raw_output payload — ANSI preserved.
@@ -183,6 +202,15 @@ type RawOutput struct {
Content string `json:"content"` Content string `json:"content"`
NewOffset int64 `json:"new_offset"` NewOffset int64 `json:"new_offset"`
Status string `json:"status,omitempty"` 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. // SearchResult is search_output's payload.
@@ -191,6 +219,14 @@ type SearchResult struct {
Truncated bool `json:"truncated"` 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 { type SearchMatch struct {
LineNo int `json:"line_no"` LineNo int `json:"line_no"`
Text string `json:"text"` Text string `json:"text"`
@@ -245,6 +281,7 @@ type TimerInfo struct {
ID string `json:"timer_id"` ID string `json:"timer_id"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
BodyTruncated bool `json:"body_truncated,omitempty"`
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all" Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused" Status string `json:"status"` // "pending" | "paused"
OwnerID string `json:"owner_process_id"` OwnerID string `json:"owner_process_id"`
@@ -288,6 +325,7 @@ type SendInputArgs struct {
Submit *bool `json:"submit"` Submit *bool `json:"submit"`
WaitMS int `json:"wait_ms"` WaitMS int `json:"wait_ms"`
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid" TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
TailMaxBytes int `json:"tail_max_bytes"`
} }
// SendInputResult is the return shape of send_input. // SendInputResult is the return shape of send_input.
@@ -306,6 +344,27 @@ type WhoAmI struct {
AvailableTools []string `json:"available_tools"` 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. // HelpResponse is the help return shape.
type HelpResponse struct { type HelpResponse struct {
Topic string `json:"topic"` Topic string `json:"topic"`
@@ -507,61 +566,51 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
return st, 0, "", nil return st, 0, "", nil
case "get_project_status": 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 { if err != nil {
return mapToolError(err) return mapToolError(err)
} }
return ps, 0, "", nil return ps, 0, "", nil
case "get_process_output": case "get_process_output":
var p struct { var p ProcessOutputArgs
ProcessID string `json:"process_id"`
Mode string `json:"mode"`
SinceOffset int64 `json:"since_offset"`
}
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil return nil, codeInvalidParams, err.Error(), nil
} }
if p.Mode == "" { if p.Mode == "" {
p.Mode = "grid" p.Mode = "grid"
} }
out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset) out, err := h.GetProcessOutput(callerID, p)
if err != nil { if err != nil {
return mapToolError(err) return mapToolError(err)
} }
return out, 0, "", nil return out, 0, "", nil
case "get_process_raw_output": case "get_process_raw_output":
var p struct { var p RawOutputArgs
ProcessID string `json:"process_id"`
SinceOffset int64 `json:"since_offset"`
}
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), 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 { if err != nil {
return mapToolError(err) return mapToolError(err)
} }
return out, 0, "", nil return out, 0, "", nil
case "search_output": case "search_output":
var p struct { var p SearchOutputArgs
ProcessID string `json:"process_id"`
Pattern string `json:"pattern"`
Kind string `json:"kind"`
Limit int `json:"limit"`
}
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil return nil, codeInvalidParams, err.Error(), nil
} }
if p.Limit <= 0 { if p.Limit <= 0 {
p.Limit = 20 p.Limit = 10
} }
if p.Kind == "" { if p.Kind == "" {
p.Kind = "rendered" 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 { if err != nil {
return mapToolError(err) return mapToolError(err)
} }
@@ -731,17 +780,15 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
return entries, 0, "", nil return entries, 0, "", nil
case "scratchpad_read": case "scratchpad_read":
var p struct { var p ScratchpadReadArgs
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil { if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil return nil, codeInvalidParams, err.Error(), nil
} }
content, rev, err := h.ScratchpadRead(p.Name) res, err := h.ScratchpadRead(p)
if err != nil { if err != nil {
return nil, codeInternal, err.Error(), nil return nil, codeInternal, err.Error(), nil
} }
return map[string]any{"content": content, "revision": rev}, 0, "", nil return res, 0, "", nil
case "scratchpad_write": case "scratchpad_write":
var p struct { 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 return map[string]any{"ok": true}, 0, "", nil
case "whoami": case "whoami":
return h.WhoAmI(callerID), 0, "", nil var p WhoAmIArgs
_ = unmarshalParamsOptional(params, &p)
return h.WhoAmI(callerID, p.IncludeTools), 0, "", nil
case "help": case "help":
var p struct { var p struct {

View File

@@ -1,164 +0,0 @@
// Package protocol defines the daemon/client control frames shared by
// transports. It intentionally contains data shapes only; app behavior stays
// in internal/app until the headless daemon split is complete.
package protocol
import (
"encoding/json"
"fmt"
"time"
)
// FrameType identifies one protocol message kind.
type FrameType string
const (
FrameHello FrameType = "hello"
FrameAuthChallenge FrameType = "auth_challenge"
FrameAuthOK FrameType = "auth_ok"
FrameAttach FrameType = "attach"
FrameDetach FrameType = "detach"
FrameProjectList FrameType = "project_list"
FrameChrome FrameType = "chrome"
FramePaneSnapshot FrameType = "pane_snapshot"
FramePaneChunk FrameType = "pane_chunk"
FrameLifecycle FrameType = "lifecycle"
FrameAttention FrameType = "attention"
FrameTrustPrompt FrameType = "trust_prompt"
FrameInput FrameType = "input"
FrameFocus FrameType = "focus"
FrameSwitchProject FrameType = "switch_project"
FrameOpenProject FrameType = "open_project"
FramePaletteCommand FrameType = "palette_command"
FrameTrustResponse FrameType = "trust_response"
FrameResize FrameType = "resize"
)
// Frame is the transport envelope. Payload is deliberately raw JSON so
// network transports can frame without knowing every message type; loopback
// transports may pass the same bytes without JSON re-encoding.
type Frame struct {
Type FrameType `json:"type"`
RequestID string `json:"request_id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewFrame marshals payload into a protocol frame.
func NewFrame[T any](typ FrameType, payload T) (Frame, error) {
b, err := json.Marshal(payload)
if err != nil {
return Frame{}, fmt.Errorf("protocol: marshal %s: %w", typ, err)
}
return Frame{Type: typ, Payload: b}, nil
}
// Decode unmarshals f.Payload into v.
func Decode[T any](f Frame) (T, error) {
var v T
if len(f.Payload) == 0 {
return v, nil
}
if err := json.Unmarshal(f.Payload, &v); err != nil {
return v, fmt.Errorf("protocol: decode %s: %w", f.Type, err)
}
return v, nil
}
type Hello struct {
Version int `json:"version"`
DaemonID string `json:"daemon_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
}
type Attach struct {
Token string `json:"token,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
TermSize Size `json:"term_size"`
}
type Detach struct {
ClientID string `json:"client_id,omitempty"`
}
type Size struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
type Project struct {
Key string `json:"key"`
Path string `json:"path"`
Name string `json:"name"`
LastActive time.Time `json:"last_active,omitempty"`
TabCount int `json:"tab_count"`
}
type ProjectList struct {
Projects []Project `json:"projects"`
}
type Chrome struct {
ProjectKey string `json:"project_key"`
Model json.RawMessage `json:"model"`
}
type PaneSnapshot struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type PaneChunk struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type LifecycleKind string
const (
LifecycleSpawned LifecycleKind = "spawned"
LifecycleExited LifecycleKind = "exited"
LifecycleClosed LifecycleKind = "closed"
LifecycleStateChanged LifecycleKind = "state_changed"
)
type Lifecycle struct {
Kind LifecycleKind `json:"kind"`
ProjectKey string `json:"project_key,omitempty"`
ChildID string `json:"child_id,omitempty"`
Child json.RawMessage `json:"child,omitempty"`
State string `json:"state,omitempty"`
}
type Input struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type Focus struct {
PaneID string `json:"pane_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type SwitchProject struct {
Key string `json:"key"`
}
type OpenProject struct {
Path string `json:"path"`
}
type PaletteCommand struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"data,omitempty"`
}
type TrustResponse struct {
ProcessID string `json:"process_id"`
Preset string `json:"preset"`
Allow bool `json:"allow"`
}
type Resize struct {
Size Size `json:"size"`
}

View File

@@ -1,67 +0,0 @@
package protocol
import (
"sync"
)
const defaultLoopbackBuffer = 64
// NewLoopbackPair returns connected in-process transports. Frames cross the
// same Send/Recv boundary as network transports, but payload bytes are passed
// directly without JSON re-encoding.
func NewLoopbackPair() (client Transport, daemon Transport) {
c2d := make(chan Frame, defaultLoopbackBuffer)
d2c := make(chan Frame, defaultLoopbackBuffer)
return &loopbackTransport{send: c2d, recv: d2c}, &loopbackTransport{send: d2c, recv: c2d}
}
type loopbackTransport struct {
send chan<- Frame
recv <-chan Frame
once sync.Once
done chan struct{}
}
func (t *loopbackTransport) init() {
if t.done == nil {
t.done = make(chan struct{})
}
}
func (t *loopbackTransport) Send(f Frame) error {
t.init()
select {
case <-t.done:
return ErrTransportClosed
case t.send <- cloneFrame(f):
return nil
}
}
func (t *loopbackTransport) Recv() (Frame, error) {
t.init()
select {
case <-t.done:
return Frame{}, ErrTransportClosed
case f, ok := <-t.recv:
if !ok {
return Frame{}, ErrTransportClosed
}
return f, nil
}
}
func (t *loopbackTransport) Close() error {
t.init()
t.once.Do(func() {
close(t.done)
})
return nil
}
func cloneFrame(f Frame) Frame {
if len(f.Payload) > 0 {
f.Payload = append([]byte(nil), f.Payload...)
}
return f
}

View File

@@ -1,51 +0,0 @@
package protocol
import "testing"
func TestLoopbackUsesFramePayload(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
sent, err := NewFrame(FrameInput, Input{PaneID: "p_123456", Bytes: []byte("hello")})
if err != nil {
t.Fatalf("NewFrame: %v", err)
}
if err := client.Send(sent); err != nil {
t.Fatalf("Send: %v", err)
}
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Type != FrameInput {
t.Fatalf("type = %q, want %q", got.Type, FrameInput)
}
payload, err := Decode[Input](got)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if payload.PaneID != "p_123456" || string(payload.Bytes) != "hello" {
t.Fatalf("payload = %#v", payload)
}
}
func TestLoopbackCopiesPayloadOnSend(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
f := Frame{Type: FramePaneChunk, Payload: []byte(`{"pane_id":"p","bytes":"aGVsbG8="}`)}
if err := client.Send(f); err != nil {
t.Fatalf("Send: %v", err)
}
f.Payload[0] = 'x'
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Payload[0] != '{' {
t.Fatalf("payload was retained instead of copied: %q", string(got.Payload))
}
}

View File

@@ -1,73 +0,0 @@
package protocol
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
)
var ErrTransportClosed = errors.New("protocol: transport closed")
// Transport carries framed daemon/client protocol messages.
type Transport interface {
Send(Frame) error
Recv() (Frame, error)
Close() error
}
// ConnTransport is a JSON-lines implementation over a stream connection.
type ConnTransport struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
}
func NewConnTransport(conn net.Conn) *ConnTransport {
return &ConnTransport{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
}
func (t *ConnTransport) Send(f Frame) error {
if t == nil || t.conn == nil {
return ErrTransportClosed
}
b, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("protocol: encode frame: %w", err)
}
if _, err := t.w.Write(append(b, '\n')); err != nil {
return err
}
return t.w.Flush()
}
func (t *ConnTransport) Recv() (Frame, error) {
if t == nil || t.conn == nil {
return Frame{}, ErrTransportClosed
}
line, err := t.r.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return Frame{}, ErrTransportClosed
}
return Frame{}, err
}
var f Frame
if err := json.Unmarshal(line, &f); err != nil {
return Frame{}, fmt.Errorf("protocol: decode frame: %w", err)
}
return f, nil
}
func (t *ConnTransport) Close() error {
if t == nil || t.conn == nil {
return nil
}
return t.conn.Close()
}

View File

@@ -6,7 +6,6 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"syscall"
cpty "github.com/creack/pty" cpty "github.com/creack/pty"
) )
@@ -20,13 +19,11 @@ type PTY struct {
// Start spawns argv with stdin/stdout/stderr attached to a new PTY sized // Start spawns argv with stdin/stdout/stderr attached to a new PTY sized
// (cols, rows). The returned PTY exposes the master fd for the parent to // (cols, rows). The returned PTY exposes the master fd for the parent to
// read from and write to. // read from and write to.
func Start(argv []string, env []string, workDir string, cols, rows uint16) (*PTY, error) { func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) {
if len(argv) == 0 { if len(argv) == 0 {
return nil, fmt.Errorf("pty: empty argv") return nil, fmt.Errorf("pty: empty argv")
} }
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
cmd.Dir = workDir
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
if env != nil { if env != nil {
cmd.Env = ensureTerm(env) cmd.Env = ensureTerm(env)
} else { } else {
@@ -91,10 +88,6 @@ func (p *PTY) Close() error {
p.master = nil p.master = nil
} }
if p.cmd != nil && p.cmd.Process != nil { if p.cmd != nil && p.cmd.Process != nil {
pid := p.cmd.Process.Pid
if pid > 0 {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
_ = p.cmd.Process.Kill() _ = p.cmd.Process.Kill()
} }
return firstErr return firstErr

View File

@@ -1,84 +0,0 @@
package pty
import (
"bytes"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestStartUsesWorkDir(t *testing.T) {
dir := t.TempDir()
p, err := Start([]string{"sh", "-c", "pwd"}, nil, dir, 80, 24)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
var out bytes.Buffer
buf := make([]byte, 256)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := p.Read(buf)
if n > 0 {
out.Write(buf[:n])
if strings.Contains(out.String(), dir) {
break
}
}
if err != nil {
break
}
}
_ = p.Wait()
if got := strings.TrimSpace(out.String()); got != dir {
t.Fatalf("pwd output = %q, want %q", got, dir)
}
}
func TestCloseKillsProcessGroup(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "sleep.pid")
env := append(os.Environ(), "PIDFILE="+pidFile)
p, err := Start([]string{"sh", "-c", "sleep 30 & echo $! > \"$PIDFILE\"; wait"}, env, "", 80, 24)
if err != nil {
t.Fatalf("Start: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
var childPID int
for time.Now().Before(deadline) {
b, err := os.ReadFile(pidFile)
if err == nil {
childPID, _ = strconv.Atoi(strings.TrimSpace(string(b)))
if childPID > 0 {
break
}
}
time.Sleep(20 * time.Millisecond)
}
if childPID <= 0 {
_ = p.Close()
t.Fatalf("background child pid was not written")
}
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
_ = p.Wait()
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
err := syscall.Kill(childPID, 0)
if errors.Is(err, syscall.ESRCH) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("background child pid %d still exists after PTY.Close", childPID)
}