14 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
da46340a82 Merge pull request 'Work through TODO fixes' (#8) from todo-fixes into main 2026-05-25 13:13:25 +01:00
d2342f99cf Show every agent tab's summary, not just the focused one
The tab bar's row-2 summary was painted only for the active tab. Add a
per-child summaryTextFor/summaryRawFor helper (active variants now
delegate to it), carry each tab's childID on its tabRect, and loop over
all visible tabs so each renders its own summary under its column.
Layout is unchanged (still 3 rows); narrow tabs clip as before.

Resolves the per-tab summary TODO item.
2026-05-25 13:06:53 +01:00
178b4437b1 Give injected agent submit Enter a longer settle delay
The trailing CR that submits orchestrator-injected input was written
only 15ms after the body, inside TUI agents' paste-coalescing window,
so codex (and other paste-detecting agents) intermittently swallowed it
as a newline and left the message composed but unsent. Centralize the
per-piece timing in a pure pieceWriteDelay helper: keep 15ms between
body lines but give the final lone Enter a 100ms settle gap so the
agent closes the preceding burst and registers the CR as submit. Covers
send_input, send_message, timers, and the spawn initial prompt (all go
through writeInput).

Resolves the codex composer-submit TODO item.
2026-05-25 13:00:54 +01:00
0725375755 Hold codex in thinking while a turn is running
Codex uses the osc_title_stability idle strategy, but it draws its
progress in the pane body ('Working … esc to interrupt'), not the OSC
title. The title goes stable mid-turn, so ~2s later the classifier
declared codex idle while it was still working. Add a thinking-promoter
pattern ((?i)esc to interrupt) to the codex built-in preset; classify()
checks promoter regexes against the rendered screen before the
title-stability verdict, so codex stays in thinking until the turn's
in-progress footer actually disappears.

Resolves the [CODEX IDLE] TODO item.
2026-05-25 12:43:56 +01:00
3022e4adeb Track per-tab summary visibility TODO 2026-05-25 12:40:23 +01:00
7b5a22618f Dispatch MCP requests concurrently per connection
handleConn processed requests serially, so a slow tool (e.g.
wait_for_pattern with a 300s timeout) monopolized the single per-agent
MCP connection and every queued call timed out behind it. Handle each
request in its own goroutine, serialize responses through a per-conn
write mutex (full response written atomically, partial writes handled),
copy the request line before handing it off (bufio reuses its buffer),
and wait on a WaitGroup before closing the conn so in-flight handlers
finish cleanly. Greeting stays sequential; notifications still get no
response.

Resolves the [MCP TIMEOUT] TODO item.
2026-05-25 12:39:31 +01:00
53f06b604f Normalize whitespace in grid get_process_output to save tokens
Grid snapshots pad every row to the full terminal width and leave the
bottom of the screen blank, so MCP grid reads carried a lot of dead
whitespace. Add normalizeGridText (CRLF/lone-CR to LF, right-trim each
line, collapse blank runs to a single blank, drop leading/trailing
blanks) and apply it to the grid branch of GetProcessOutput only.
Stream output, raw output, and WaitForPattern matching are untouched.

Resolves the terminal-read newline/token-waste TODO item.
2026-05-25 12:33:59 +01:00
50fd7be70d Escalate agent Close to SIGKILL so it terminates in one action
Agent 'Close' (agent-close) sent a single SIGTERM via Session.Kill and
never escalated, so an agent that traps/ignores SIGTERM (e.g. opencode)
stayed in the running tab bar until the user closed it again. Add
Session.Terminate, which reuses terminateAndWait (SIGTERM, wait, then
SIGKILL) but preserves the session entry so the exited pane stays
readable, and route handleChildClose's agent path through it in a
goroutine to keep the UI responsive during the stop timeout.

Resolves the opencode double-close TODO item.
2026-05-25 12:30:13 +01:00
96f7c66d5f Add scratchpad_delete MCP tool
Mirrors the existing scratchpad_* tools end-to-end: catalog schema,
dispatch, ToolHost.ScratchpadDelete, and a host method that delegates to
scratchpad.Store.Delete and fires scratchpadsChanged() on success so the
sidebar refreshes. Missing-pad errors surface rather than being masked.

Resolves the [MCP SCRATCHPAD DELETE] TODO item.
2026-05-25 12:23:58 +01:00
f61788eff2 Work through TODO fixes 2026-05-21 15:45:01 +01:00
c1b66f9f8a Merge pull request 'Show idle state in the top tab bar + release v0.0.7' (#7) from worktree-timers-cancel-on-close into main 2026-05-18 13:25:38 +01:00
412b1167a2 Cancel pending timers when a child is closed (#6)
Co-authored-by: Harry Bayliss <harry@hjb.dev>
Co-committed-by: Harry Bayliss <harry@hjb.dev>
2026-05-18 12:46:50 +01:00
30 changed files with 2060 additions and 228 deletions

1
.gitignore vendored
View File

@@ -7,4 +7,5 @@ spike-report-*.txt
/bin/ /bin/
/spike /spike
/.worktrees/ /.worktrees/
/.claude/worktrees/
internal/harness/.artifacts/ internal/harness/.artifacts/

View File

@@ -6,6 +6,49 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- MCP clients can now call `scratchpad_delete` with a scratchpad name
to remove a shared project scratchpad.
### Changed
- The tab bar now shows each visible agent tab's own summary instead
of only rendering the focused tab's summary.
- `get_process_output` now returns aggressively canonical terminal text
by default, removing ANSI/control noise, decorative borders, duplicate
status churn, and volatile progress/timer fragments; raw PTY bytes are
opt-in with `raw:true`.
- MCP responses now use slimmer defaults: tool-call JSON is no longer
duplicated into text content, large output and scratchpad reads are
capped with truncation metadata, and `whoami` / `get_project_status`
only include full tool lists when `include_tools` is requested.
### Fixed
- Injected agent input now sends the submit Enter as a separated,
settled keystroke so messages reliably submit instead of sometimes
sitting unsent in the composer.
- Codex agents are no longer reported idle while a turn is still
running.
- Slow MCP tool calls such as `wait_for_pattern` no longer block later
tool calls on the same MCP connection.
- Closing an agent now escalates from SIGTERM to SIGKILL when needed,
so agents that ignore SIGTERM disappear from the running tab bar
after one Close action while keeping their exited pane readable.
- Sidebar timer indicators now repaint as their visible countdown
value changes, so labels progress from minutes to seconds without
waiting for unrelated terminal output or focus changes.
- Raw terminal focused actions now show a single `Close` row instead
of separate stop/delete-style lifecycle choices that did the same
thing for ephemeral terminal panes.
- Restarting a process from the palette now restores the focused pane
and host chrome before waiting for the old process to exit, so the
tab bar and sidebar do not disappear during slow restarts.
- Deleting the focused scratchpad now moves focus to another
scratchpad when one exists, or back to a running terminal/agent
instead of dropping into the empty state.
- Multiline paste into raw terminal and command panes no longer pays
the agent-specific per-Enter delay, making large pasted input arrive
as one PTY write outside Claude/Codex/OpenCode panes.
## [0.0.7] - 2026-05-18 ## [0.0.7] - 2026-05-18
### Added ### Added

View File

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

View File

@@ -326,6 +326,15 @@ func Run(ctx context.Context, opts Options) error {
} }
}() }()
// Timer sidebar refresher: countdown labels are computed at draw
// time, so wake the sidebar when the next visible timer bucket is
// due to change even if no child PTY output arrives.
wg.Add(1)
go func() {
defer wg.Done()
st.runTimerSidebarRefresher(ctx)
}()
// Marquee ticker: while a focused sidebar row's name overflows the // Marquee ticker: while a focused sidebar row's name overflows the
// rail width, advance the pause-scroll-pause animation by marking // rail width, advance the pause-scroll-pause animation by marking
// the sidebar dirty every marqueeStep. The chrome ticker above does // the sidebar dirty every marqueeStep. The chrome ticker above does
@@ -505,7 +514,14 @@ func (st *uiState) dbgf(format string, args ...any) {
} }
func (st *uiState) activeSummaryText(width int) string { func (st *uiState) activeSummaryText(width int) string {
text := st.activeSummaryRaw() st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
return st.summaryTextFor(active, width)
}
func (st *uiState) summaryTextFor(childID string, width int) string {
text := st.summaryRawFor(childID)
if text == "" || width <= 0 { if text == "" || width <= 0 {
return "" return ""
} }
@@ -516,7 +532,14 @@ func (st *uiState) activeSummaryText(width int) string {
} }
func (st *uiState) activeSummaryRaw() string { func (st *uiState) activeSummaryRaw() string {
if st.summaries == nil { st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
return st.summaryRawFor(active)
}
func (st *uiState) summaryRawFor(childID string) string {
if st.summaries == nil || childID == "" {
return "" return ""
} }
st.settingsMu.Lock() st.settingsMu.Lock()
@@ -525,13 +548,7 @@ func (st *uiState) activeSummaryRaw() string {
if !enabled { if !enabled {
return "" return ""
} }
st.mu.Lock() sum := st.summaries.Summary(childID)
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
return ""
}
sum := st.summaries.Summary(active)
text := strings.TrimSpace(sum.Text) text := strings.TrimSpace(sum.Text)
if text == "" { if text == "" {
return "" return ""
@@ -671,6 +688,20 @@ func (st *uiState) clearViewportArea() {
_, _ = os.Stdout.WriteString(b.String()) _, _ = os.Stdout.WriteString(b.String())
} }
func (st *uiState) repaintFocusedWithChrome() {
st.mu.Lock()
padFocused := st.focusedPad != ""
st.mu.Unlock()
if padFocused {
st.repaintFocusedPad()
} else {
st.repaintFocused()
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) restartFocusedCommand(processID string) { func (st *uiState) restartFocusedCommand(processID string) {
c := st.sess.FindChild(processID) c := st.sess.FindChild(processID)
if c == nil || c.Kind != KindCommand { if c == nil || c.Kind != KindCommand {
@@ -687,14 +718,18 @@ func (st *uiState) restartFocusedCommand(processID string) {
st.repaintNextPTYBudget = 2 st.repaintNextPTYBudget = 2
st.mu.Unlock() st.mu.Unlock()
st.outMu.Lock() st.repaintFocusedWithChrome()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil { if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err)) st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return return
} }
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
st.moveToViewportOrigin() st.moveToViewportOrigin()
st.drawTabBar() st.drawTabBar()
st.drawSidebar() st.drawSidebar()
@@ -741,12 +776,7 @@ func (st *uiState) notifyAttention(childID, reason string) {
} }
func (st *uiState) scratchpadsChanged() { func (st *uiState) scratchpadsChanged() {
st.padsCacheMu.Lock() st.invalidateScratchpadsCache()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.drawSidebar() st.drawSidebar()
st.mu.Lock() st.mu.Lock()
focusedPad := st.focusedPad focusedPad := st.focusedPad
@@ -756,6 +786,15 @@ func (st *uiState) scratchpadsChanged() {
} }
} }
func (st *uiState) invalidateScratchpadsCache() {
st.padsCacheMu.Lock()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
}
// OnChildSpawned auto-focuses the new child when the spawn came from // OnChildSpawned auto-focuses the new child when the spawn came from
// the user (palette, persistence restore, or an external MCP client with // the user (palette, persistence restore, or an external MCP client with
// no resolved identity). When ParentID is set — meaning a patterm-managed // no resolved identity). When ParentID is set — meaning a patterm-managed
@@ -1143,6 +1182,55 @@ func (st *uiState) markSidebarDirty() {
} }
} }
func (st *uiState) runTimerSidebarRefresher(ctx context.Context) {
if st.timers == nil {
<-ctx.Done()
return
}
changes := st.timers.changeEvents()
var timer *time.Timer
var timerC <-chan time.Time
stop := func() {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer = nil
timerC = nil
}
arm := func() {
stop()
wait, ok := st.timers.nextSidebarRefreshAfter(time.Now())
if !ok {
return
}
if wait < timerSidebarMinRefresh {
wait = timerSidebarMinRefresh
}
timer = time.NewTimer(wait)
timerC = timer.C
}
defer stop()
arm()
for {
select {
case <-ctx.Done():
return
case <-changes:
st.markSidebarDirty()
arm()
case <-timerC:
st.markSidebarDirty()
arm()
}
}
}
func (st *uiState) invalidateChromeCache() { func (st *uiState) invalidateChromeCache() {
st.chromeCacheMu.Lock() st.chromeCacheMu.Lock()
st.tabBarCache = "" st.tabBarCache = ""
@@ -1433,9 +1521,10 @@ func (st *uiState) processStdin(chunk []byte) {
if st.focusedID != "" { if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning { if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner() prev := c.Owner()
// InjectAsUser splits Enter bytes onto their own // Agent panes split Enter bytes onto their own writes
// writes so claude / codex / opencode don't treat a // so claude / codex / opencode don't treat a
// "text\r" batch as a paste. // "text\r" batch as a paste. Raw terminals keep paste
// bytes batched.
_ = c.InjectAsUser(forward) _ = c.InjectAsUser(forward)
if st.summaries != nil { if st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward) st.summaries.ObserveHumanInput(c.ID, forward)
@@ -2131,20 +2220,47 @@ func (st *uiState) handlePadDelete(name string) {
st.repaintFocused() st.repaintFocused()
return return
} }
st.mu.Lock()
wasFocused := st.focusedPad == name
st.mu.Unlock()
if err := st.pads.Delete(name); err != nil { if err := st.pads.Delete(name); err != nil {
st.flashError(fmt.Sprintf("delete %s: %v", name, err)) st.flashError(fmt.Sprintf("delete %s: %v", name, err))
return return
} }
if wasFocused {
st.invalidateScratchpadsCache()
if entries := st.padsList(); len(entries) > 0 {
next := entries[0].Name
st.mu.Lock() st.mu.Lock()
if st.focusedPad == name { st.focusedPad = next
st.focusedPad = "" st.focusedID = ""
st.focusedName = next
if st.padOffsetName != next {
st.padOffset = 0
st.padOffsetName = next
} }
st.mu.Unlock() st.mu.Unlock()
st.scratchpadsChanged() st.repaintFocusedWithChrome()
st.repaintFocused() return
}
if next := firstRunningTopLevel(st.sess.Children()); next != nil {
st.focusProcess(next.ID)
return
}
st.mu.Lock()
st.focusedPad = ""
st.focusedName = ""
st.padOffset = 0
st.padOffsetName = ""
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar() st.drawTabBar()
st.drawSidebar() st.drawSidebar()
st.drawStatusLine() st.drawStatusLine()
return
}
st.scratchpadsChanged()
st.repaintFocusedWithChrome()
} }
func (st *uiState) handlePadRename(oldName, newName string) { func (st *uiState) handlePadRename(oldName, newName string) {
@@ -2237,11 +2353,9 @@ func (st *uiState) handleChildRename(childID, newName string) {
st.drawStatusLine() st.drawStatusLine()
} }
// handleChildClose removes a child entry entirely. For agents this is // handleChildClose removes a child entry entirely for process deletes.
// equivalent to a SIGTERM kill (the entry is ephemeral and disappears // For agent Close, it terminates the PTY with escalation but preserves
// from the session once the PTY exits). For command processes it's // the exited pane so the user can still read the corpse.
// equivalent to the MCP close_process tool: SIGKILL if alive, then
// drop the entry so it stops appearing in the switch/restart lists.
func (st *uiState) handleChildClose(childID string, kill bool) { func (st *uiState) handleChildClose(childID string, kill bool) {
if childID == "" { if childID == "" {
st.repaintFocused() st.repaintFocused()
@@ -2256,7 +2370,11 @@ func (st *uiState) handleChildClose(childID string, kill bool) {
if kill { if kill {
_ = st.sess.Close(childID, syscall.SIGKILL) _ = st.sess.Close(childID, syscall.SIGKILL)
} else { } else {
_ = st.sess.Kill(childID, syscall.SIGTERM) go func() {
if err := st.sess.Terminate(childID, syscall.SIGTERM); err != nil {
logf("terminate child %s: %v", childID, err)
}
}()
} }
st.repaintFocused() st.repaintFocused()
st.drawTabBar() st.drawTabBar()
@@ -2293,8 +2411,19 @@ func (st *uiState) handleProcRestart(childID string) {
return return
} }
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.mu.Lock()
if c.ID == st.focusedID {
st.renderer = newViewportRenderer(layout)
st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 2
}
st.mu.Unlock()
st.repaintFocusedWithChrome()
if err := st.sess.Restart(childID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil { if err := st.sess.Restart(childID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err)) st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return return
} }
st.repaintFocused() st.repaintFocused()

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

@@ -26,6 +26,11 @@ import (
// false positives (timestamps, exit codes, etc.). // false positives (timestamps, exit codes, etc.).
var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`) var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`)
const (
agentInterPieceDelay = 15 * time.Millisecond
agentSubmitSettleDelay = 100 * time.Millisecond
)
type ChildStatus string type ChildStatus string
const ( const (
@@ -527,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 {
@@ -625,25 +636,25 @@ func (c *Child) InjectAsOrchestrator(b []byte) error {
} }
// writeInput is the shared PTY write path used by both injection // writeInput is the shared PTY write path used by both injection
// flavours. Each Enter byte (CR or LF) is split onto its own write // flavours. Agent panes split each Enter byte (CR or LF) onto its own
// with a brief delay so TUI agents with paste-detection (claude, // write with a brief delay so TUI agents with paste-detection (claude,
// codex, opencode) don't coalesce a trailing CR into the text that // codex, opencode) don't coalesce a trailing CR into the text that
// preceded it. Without the split, `pty.Write([]byte("hello\r"))` // preceded it. Raw terminals and command panes receive the original
// arrives at the agent as one read() and gets treated as multi-line // byte stream in one write; otherwise a multiline paste pays the agent
// pasted content rather than "key Enter". // workaround's delay once per line.
func (c *Child) writeInput(b []byte) error { func (c *Child) writeInput(b []byte) error {
pty := c.PTY() pty := c.PTY()
if pty == nil { if pty == nil {
return errors.New("child has no pty") return errors.New("child has no pty")
} }
pieces := splitOnEnter(b) pieces := inputWritePieces(c.Kind, b)
if len(pieces) <= 1 { if len(pieces) <= 1 {
_, err := pty.Write(b) _, err := pty.Write(b)
return err return err
} }
for i, piece := range pieces { for i, piece := range pieces {
if i > 0 { if delay := pieceWriteDelay(i, len(pieces), piece); delay > 0 {
time.Sleep(15 * time.Millisecond) time.Sleep(delay)
} }
if _, err := pty.Write(piece); err != nil { if _, err := pty.Write(piece); err != nil {
return err return err
@@ -652,6 +663,27 @@ func (c *Child) writeInput(b []byte) error {
return nil return nil
} }
func inputWritePieces(kind ChildKind, b []byte) [][]byte {
if kind != KindAgent {
return [][]byte{b}
}
return splitOnEnter(b)
}
func pieceWriteDelay(index, total int, piece []byte) time.Duration {
if index == 0 {
return 0
}
if index == total-1 && isLoneEnter(piece) {
return agentSubmitSettleDelay
}
return agentInterPieceDelay
}
func isLoneEnter(piece []byte) bool {
return len(piece) == 1 && (piece[0] == '\r' || piece[0] == '\n')
}
func mintIdentity() string { func mintIdentity() string {
var buf [12]byte var buf [12]byte
_, _ = rand.Read(buf[:]) _, _ = rand.Read(buf[:])

View File

@@ -0,0 +1,90 @@
package app
import (
"bytes"
"testing"
"time"
)
func TestInputWritePiecesOnlySplitAgentEnters(t *testing.T) {
in := []byte("alpha\nbeta\rgamma")
for _, kind := range []ChildKind{KindTerminal, KindCommand} {
t.Run(string(kind), func(t *testing.T) {
got := inputWritePieces(kind, in)
if len(got) != 1 || !bytes.Equal(got[0], in) {
t.Fatalf("inputWritePieces(%s) = %#v, want one original chunk", kind, got)
}
})
}
got := inputWritePieces(KindAgent, in)
if len(got) != 5 {
t.Fatalf("agent pieces len = %d, want 5 (%#v)", len(got), got)
}
want := [][]byte{[]byte("alpha"), []byte("\n"), []byte("beta"), []byte("\r"), []byte("gamma")}
for i := range want {
if !bytes.Equal(got[i], want[i]) {
t.Fatalf("agent piece %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestPieceWriteDelay(t *testing.T) {
cases := []struct {
name string
index int
total int
piece []byte
want time.Duration
}{
{
name: "first piece",
index: 0,
total: 3,
piece: []byte("body"),
want: 0,
},
{
name: "middle body piece",
index: 1,
total: 3,
piece: []byte("body"),
want: agentInterPieceDelay,
},
{
name: "final carriage return submit",
index: 1,
total: 2,
piece: []byte("\r"),
want: agentSubmitSettleDelay,
},
{
name: "final newline submit",
index: 1,
total: 2,
piece: []byte("\n"),
want: agentSubmitSettleDelay,
},
{
name: "final non-enter piece",
index: 2,
total: 3,
piece: []byte("tail"),
want: agentInterPieceDelay,
},
{
name: "standalone enter fast path",
index: 0,
total: 1,
piece: []byte("\r"),
want: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := pieceWriteDelay(tc.index, tc.total, tc.piece); got != tc.want {
t.Fatalf("pieceWriteDelay(%d, %d, %q) = %s, want %s", tc.index, tc.total, tc.piece, got, tc.want)
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"unicode"
"github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
@@ -64,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,
@@ -352,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{
@@ -364,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()
@@ -398,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 = 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:
@@ -410,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 {
@@ -446,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})
} }
} }
@@ -587,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
} }
@@ -598,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
} }
@@ -812,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) {
@@ -832,7 +917,15 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
return err return err
} }
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI { func (h *toolHost) ScratchpadDelete(name string) error {
err := h.pads.Delete(name)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return err
}
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),
@@ -840,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()
@@ -1000,22 +1095,101 @@ 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, "")
} }
func normalizeGridText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
pendingBlank := false
for _, line := range lines {
line = strings.TrimRightFunc(line, unicode.IsSpace)
if line == "" {
if len(out) > 0 {
pendingBlank = true
}
continue
}
if pendingBlank {
out = append(out, "")
pendingBlank = false
}
out = append(out, line)
}
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)
// //
@@ -1045,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).
@@ -1091,7 +1283,7 @@ func availableToolsForRole(role mcp.CallerRole) []string {
"send_input", "send_message", "request_human_attention", "send_input", "send_message", "request_human_attention",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all", "timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list", "timer_cancel", "timer_pause", "timer_resume", "timer_list",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete",
"whoami", "help", "whoami", "help",
} }
if role == mcp.RoleOrchestrator { if role == mcp.RoleOrchestrator {
@@ -1127,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":
@@ -1146,8 +1338,8 @@ func helpFor(topic string) mcp.HelpResponse {
case "scratchpads": case "scratchpads":
return mcp.HelpResponse{ return mcp.HelpResponse{
Topic: "scratchpads", Topic: "scratchpads",
Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional.", Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional; delete removes a pad by name.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"}, RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete"},
} }
case "timers": case "timers":
return mcp.HelpResponse{ return mcp.HelpResponse{

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

@@ -57,6 +57,21 @@ func TestClassifyTitleStability(t *testing.T) {
} }
} }
func TestClassifyTitleStabilityThinkingPatternOverridesIdle(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStability,
idleThresholdMS: 2000,
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `(?i)esc to interrupt`)},
}
screen := []byte("• Working (5s • esc to interrupt)")
if got, _ := classify(cfg, false, false, 9999, 5000, "codex", nil, screen); got != StateThinking {
t.Fatalf("thinking screen marker: got %q want %q", got, StateThinking)
}
if got, _ := classify(cfg, false, false, 9999, 5000, "codex", nil, []byte(">_")); got != StateIdle {
t.Fatalf("stable title without marker: got %q want %q", got, StateIdle)
}
}
func TestClassifyTitleStatus(t *testing.T) { func TestClassifyTitleStatus(t *testing.T) {
cfg := &resolvedIdleDetection{ cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStatus, strategy: StrategyOSCTitleStatus,

View File

@@ -267,9 +267,18 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
out = append(out, out = append(out,
paletteItem{label: "Rename", hint: "rename agent · " + name, paletteItem{label: "Rename", hint: "rename agent · " + name,
action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)", paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM, escalates)",
action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused},
) )
case KindTerminal:
out = append(out,
paletteItem{label: "Rename", hint: "rename terminal · " + name,
action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close", hint: "close terminal · " + name + " (SIGTERM)",
action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused},
paletteItem{label: "Restart", hint: "restart terminal · " + name,
action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused},
)
default: default:
out = append(out, out = append(out,
paletteItem{label: "Rename", hint: "rename process · " + name, paletteItem{label: "Rename", hint: "rename process · " + name,

View File

@@ -83,6 +83,25 @@ func TestContextItemsProcess(t *testing.T) {
} }
} }
func TestContextItemsTerminalUsesCloseNotStop(t *testing.T) {
c := makeFakeChild("tid", "terminal", KindTerminal)
p := newPalette([]*Child{c}, "tid", "", preset.Set{})
if _, it := findItem(p, "proc-stop"); it == nil || it.label != "Close" {
t.Fatalf("terminal close row missing or mislabelled: %+v", it)
}
if _, it := findItem(p, "proc-restart"); it == nil {
t.Fatalf("terminal restart row missing")
}
if i, _ := findItem(p, "proc-delete"); i != -1 {
t.Fatalf("terminal should not show a separate delete/close row, found at %d", i)
}
for i, it := range p.items {
if it.label == "Stop" {
t.Fatalf("terminal should not show Stop row, found at %d", i)
}
}
}
func TestContextItemsAppearAboveSwitch(t *testing.T) { func TestContextItemsAppearAboveSwitch(t *testing.T) {
// Two children so there's still a non-focused switch entry to compare // Two children so there's still a non-focused switch entry to compare
// against (the focused child is suppressed from the Open section). // against (the focused child is suppressed from the Open section).

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",
@@ -104,3 +106,44 @@ func TestStripANSIBytesEquivalence(t *testing.T) {
} }
} }
} }
func TestNormalizeGridText(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "line endings",
in: "one\r\ntwo\rthree",
want: "one\ntwo\nthree",
},
{
name: "trailing whitespace",
in: "one \ntwo\t\t\nthree",
want: "one\ntwo\nthree",
},
{
name: "collapse blank runs",
in: "one\n\n\n two\n \n\t\nthree",
want: "one\n\n two\n\nthree",
},
{
name: "trim leading and trailing blanks",
in: "\n \n\t\none\n\n",
want: "one",
},
{
name: "already clean",
in: "one\n\ntwo\nthree",
want: "one\n\ntwo\nthree",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeGridText(tc.in); got != tc.want {
t.Fatalf("normalizeGridText(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,137 @@
package app
import (
"errors"
"io"
"os"
"testing"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
)
func silenceStdout(t *testing.T) {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stdout: %v", err)
}
done := make(chan struct{})
go func() {
_, _ = io.Copy(io.Discard, r)
close(done)
}()
os.Stdout = w
t.Cleanup(func() {
os.Stdout = old
_ = w.Close()
<-done
_ = r.Close()
})
}
func newScratchpadDeleteTestState(t *testing.T) (*uiState, *scratchpad.Store) {
t.Helper()
t.Setenv("XDG_DATA_HOME", t.TempDir())
pads, err := scratchpad.Open("scratchpad-delete-test")
if err != nil {
t.Fatalf("scratchpad.Open: %v", err)
}
sess := NewSession(t.TempDir(), "scratchpad-delete-test")
t.Cleanup(sess.Shutdown)
st := &uiState{
sess: sess,
pads: pads,
hostCols: 120,
hostRows: 40,
chromeWake: make(chan struct{}, 1),
}
return st, pads
}
func TestDeletingFocusedScratchpadFocusesAnotherPad(t *testing.T) {
silenceStdout(t)
st, pads := newScratchpadDeleteTestState(t)
if _, err := pads.Write("alpha.md", "alpha", ""); err != nil {
t.Fatalf("write alpha: %v", err)
}
if _, err := pads.Write("beta.md", "beta", ""); err != nil {
t.Fatalf("write beta: %v", err)
}
st.focusedPad = "alpha.md"
st.focusedName = "alpha.md"
st.padOffsetName = "alpha.md"
st.padOffset = 3
st.handlePadDelete("alpha.md")
if st.focusedPad != "beta.md" {
t.Fatalf("focusedPad = %q, want beta.md", st.focusedPad)
}
if st.focusedID != "" {
t.Fatalf("focusedID = %q, want empty while another pad is focused", st.focusedID)
}
if st.padOffset != 0 || st.padOffsetName != "beta.md" {
t.Fatalf("pad offset = (%q,%d), want (beta.md,0)", st.padOffsetName, st.padOffset)
}
}
func TestDeletingLastFocusedScratchpadFocusesRunningChild(t *testing.T) {
silenceStdout(t)
st, pads := newScratchpadDeleteTestState(t)
if _, err := pads.Write("only.md", "only", ""); err != nil {
t.Fatalf("write only: %v", err)
}
child := makeFakeChild("pid", "devserver", KindCommand)
addChild(st.sess, child)
st.focusedPad = "only.md"
st.focusedName = "only.md"
st.handlePadDelete("only.md")
if st.focusedPad != "" {
t.Fatalf("focusedPad = %q, want empty after falling back to child", st.focusedPad)
}
if st.focusedID != "pid" {
t.Fatalf("focusedID = %q, want pid", st.focusedID)
}
}
type scratchpadChangeRecorder struct {
count int
}
func (r *scratchpadChangeRecorder) scratchpadsChanged() {
r.count++
}
func TestToolHostScratchpadDeleteRemovesPadAndRefreshes(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
pads, err := scratchpad.Open("scratchpad-delete-host-test")
if err != nil {
t.Fatalf("scratchpad.Open: %v", err)
}
if _, err := pads.Write("doomed.md", "content", ""); err != nil {
t.Fatalf("write doomed.md: %v", err)
}
recorder := &scratchpadChangeRecorder{}
host := newToolHost(nil, pads, nil, preset.Set{}, nil, 120, 40)
host.scratch = recorder
if err := host.ScratchpadDelete("doomed.md"); err != nil {
t.Fatalf("ScratchpadDelete: %v", err)
}
if recorder.count != 1 {
t.Fatalf("scratchpadsChanged calls = %d, want 1", recorder.count)
}
if _, _, err := pads.Read("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read deleted pad error = %v, want os.ErrNotExist", err)
}
if err := host.ScratchpadDelete("doomed.md"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("delete missing error = %v, want os.ErrNotExist", err)
}
if recorder.count != 1 {
t.Fatalf("scratchpadsChanged calls after failed delete = %d, want 1", recorder.count)
}
}

View File

@@ -395,6 +395,20 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
return nil return nil
} }
// Terminate stops a live child with SIGTERM/SIGKILL escalation but
// leaves its session entry intact so callers can keep showing the
// exited pane.
func (s *Session) Terminate(id string, sig syscall.Signal) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such process %q", id)
}
if c.IsLive() {
terminateAndWait(c, sig, childStopTimeout)
}
return nil
}
// mintUniqueIDLocked mints an opaque process_id (SPEC §7) and retries // mintUniqueIDLocked mints an opaque process_id (SPEC §7) and retries
// if it collides with an existing entry. Caller holds s.mu. // if it collides with an existing entry. Caller holds s.mu.
func (s *Session) mintUniqueIDLocked() string { func (s *Session) mintUniqueIDLocked() string {

View File

@@ -1,6 +1,7 @@
package app package app
import ( import (
"strings"
"syscall" "syscall"
"testing" "testing"
"time" "time"
@@ -101,6 +102,50 @@ func TestSpawnInstallsIdleDetectionBeforePublish(t *testing.T) {
} }
} }
func TestTerminateEscalatesWithoutRemovingEntry(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
c, err := sess.Spawn(SpawnSpec{
Kind: KindAgent,
Argv: []string{"sh", "-c", "trap '' TERM; echo ready; while :; do sleep 1; done"},
}, 80, 24)
if err != nil {
t.Fatalf("spawn: %v", err)
}
t.Cleanup(func() {
if c.IsLive() {
_ = c.signal(syscall.SIGKILL)
}
})
waitUntilLive(t, c)
waitForStreamText(t, c, "ready")
start := time.Now()
if err := sess.Terminate(c.ID, syscall.SIGTERM); err != nil {
t.Fatalf("Terminate: %v", err)
}
if elapsed := time.Since(start); elapsed < childStopTimeout {
t.Fatalf("Terminate returned before SIGKILL fallback: elapsed=%s timeout=%s", elapsed, childStopTimeout)
}
waitUntilNotLive(t, c)
if got := sess.FindChild(c.ID); got == nil {
t.Fatalf("Terminate removed child entry %s", c.ID)
}
}
func waitForStreamText(t *testing.T, c *Child, want string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
b, _ := c.StreamRead(0)
if strings.Contains(string(b), want) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("child %s never wrote %q", c.ID, want)
}
func waitUntilLive(t *testing.T, c *Child) { func waitUntilLive(t *testing.T, c *Child) {
t.Helper() t.Helper()
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)

View File

@@ -52,6 +52,41 @@ func TestWrapSidebarSummaryKeepsWordBoundaries(t *testing.T) {
} }
} }
func TestSummaryTextForSelectsChildAndClips(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
cfg := defaultSettings()
st := &uiState{
sess: sess,
settings: cfg,
summaries: newSummaryManager(sess, t.TempDir(), preset.Set{}, func() autoSummarySettings {
return cfg.AutoSummary.clone()
}, nil, nil),
}
st.summaries.mu.Lock()
st.summaries.entries["a1"] = &summaryEntry{state: summaryState{Text: " alpha summary "}}
st.summaries.entries["a2"] = &summaryEntry{state: summaryState{Text: "beta summary"}}
st.summaries.entries["empty"] = &summaryEntry{state: summaryState{Text: " "}}
st.summaries.entries["long"] = &summaryEntry{state: summaryState{Text: "abcdefghijklmnopqrstuvwxyz"}}
st.summaries.mu.Unlock()
if got := st.summaryTextFor("a2", 20); got != "beta summary" {
t.Fatalf("summaryTextFor(a2) = %q, want beta summary", got)
}
if got := st.summaryTextFor("empty", 20); got != "" {
t.Fatalf("summaryTextFor(empty) = %q, want empty", got)
}
if got := st.summaryTextFor("long", 8); got != "abcdefg…" {
t.Fatalf("summaryTextFor(long) = %q, want abcdefg…", got)
}
st.settingsMu.Lock()
st.settings.AutoSummary.Enabled = false
st.settingsMu.Unlock()
if got := st.summaryTextFor("a1", 20); got != "" {
t.Fatalf("summaryTextFor disabled = %q, want empty", got)
}
}
func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) { func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) {
sess := NewSession(t.TempDir(), "test") sess := NewSession(t.TempDir(), "test")
c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "") c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "")

View File

@@ -59,6 +59,7 @@ func (st *uiState) drawTabBar() {
newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing
type tabRect struct { type tabRect struct {
childID string
startCol int startCol int
width int width int
label string label string
@@ -66,8 +67,6 @@ func (st *uiState) drawTabBar() {
glyphStyle string glyphStyle string
active bool active bool
} }
activeTab := -1
// Reserve space at the right edge for "+ new". If there are too // Reserve space at the right edge for "+ new". If there are too
// many tabs to fit even at minTabWidth, drop tabs from the right // many tabs to fit even at minTabWidth, drop tabs from the right
// until they do. The current focus stays visible. // until they do. The current focus stays visible.
@@ -139,6 +138,7 @@ func (st *uiState) drawTabBar() {
labelW = utf8.RuneCountInString(label) labelW = utf8.RuneCountInString(label)
} }
tabs = append(tabs, tabRect{ tabs = append(tabs, tabRect{
childID: c.ID,
startCol: col, startCol: col,
width: w, width: w,
label: label, label: label,
@@ -146,9 +146,6 @@ func (st *uiState) drawTabBar() {
glyphStyle: glyphStyle, glyphStyle: glyphStyle,
active: active, active: active,
}) })
if tabs[len(tabs)-1].active {
activeTab = len(tabs) - 1
}
col += w col += w
} }
} }
@@ -224,10 +221,9 @@ func (st *uiState) drawTabBar() {
hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset) hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset)
} }
if activeTab >= 0 { for _, tab := range tabs {
tab := tabs[activeTab]
summaryWidth := tab.width - 2 summaryWidth := tab.width - 2
if summary := st.activeSummaryText(summaryWidth); summary != "" { if summary := st.summaryTextFor(tab.childID, summaryWidth); summary != "" {
fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset) fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset)
} }
} }

View File

@@ -58,6 +58,7 @@ type timerManager struct {
mu sync.Mutex mu sync.Mutex
nextID int nextID int
timers map[string]*pendingTimer timers map[string]*pendingTimer
changes chan struct{}
// fireFn is the callback used to deliver the body to the owning // fireFn is the callback used to deliver the body to the owning
// process. Decoupled so tests can substitute a recorder. Defaults // process. Decoupled so tests can substitute a recorder. Defaults
@@ -69,11 +70,23 @@ func newTimerManager(sess *Session) *timerManager {
m := &timerManager{ m := &timerManager{
sess: sess, sess: sess,
timers: make(map[string]*pendingTimer), timers: make(map[string]*pendingTimer),
changes: make(chan struct{}, 1),
} }
m.fireFn = defaultFireFn m.fireFn = defaultFireFn
return m return m
} }
func (m *timerManager) changeEvents() <-chan struct{} {
return m.changes
}
func (m *timerManager) notifyChanged() {
select {
case m.changes <- struct{}{}:
default:
}
}
func defaultFireFn(owner *Child, body, label string) { func defaultFireFn(owner *Child, body, label string) {
if owner == nil || !owner.IsLive() { if owner == nil || !owner.IsLive() {
return return
@@ -121,6 +134,7 @@ func (m *timerManager) TimerSet(ownerID string, body, label string, seconds floa
m.timers[id] = t m.timers[id] = t
m.mu.Unlock() m.mu.Unlock()
t.rt = time.AfterFunc(d, func() { m.fireDelay(id) }) t.rt = time.AfterFunc(d, func() { m.fireDelay(id) })
m.notifyChanged()
return id, nil return id, nil
} }
@@ -136,6 +150,7 @@ func (m *timerManager) fireDelay(id string) {
body, label := t.body, t.label body, label := t.body, t.label
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -214,6 +229,7 @@ func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, l
} }
m.timers[id] = t m.timers[id] = t
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
resp.ID = id resp.ID = id
resp.Status = "pending" resp.Status = "pending"
return resp, nil return resp, nil
@@ -231,6 +247,7 @@ func (m *timerManager) fireIdleMaxWait(id string) {
body, label := t.body, t.label body, label := t.body, t.label
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -291,6 +308,9 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
delete(m.timers, id) delete(m.timers, id)
} }
m.mu.Unlock() m.mu.Unlock()
if len(firedIDs) > 0 {
m.notifyChanged()
}
for _, f := range fires { for _, f := range fires {
m.fireFn(f.owner, f.body, f.label) m.fireFn(f.owner, f.body, f.label)
} }
@@ -320,7 +340,7 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
// legitimate fire and leave the parent never notified. // legitimate fire and leave the parent never notified.
func (m *timerManager) onChildClosed(childID string) { func (m *timerManager) onChildClosed(childID string) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() changed := false
for id, t := range m.timers { for id, t := range m.timers {
if t.ownerID == childID { if t.ownerID == childID {
if t.rt != nil { if t.rt != nil {
@@ -329,6 +349,7 @@ func (m *timerManager) onChildClosed(childID string) {
} }
t.status = timerStatusCanceled t.status = timerStatusCanceled
delete(m.timers, id) delete(m.timers, id)
changed = true
continue continue
} }
if !contains(t.watched, childID) { if !contains(t.watched, childID) {
@@ -344,6 +365,7 @@ func (m *timerManager) onChildClosed(childID string) {
if t.idleBaseline != nil { if t.idleBaseline != nil {
delete(t.idleBaseline, childID) delete(t.idleBaseline, childID)
} }
changed = true
if len(t.watched) == 0 { if len(t.watched) == 0 {
if t.rt != nil { if t.rt != nil {
t.rt.Stop() t.rt.Stop()
@@ -353,6 +375,10 @@ func (m *timerManager) onChildClosed(childID string) {
delete(m.timers, id) delete(m.timers, id)
} }
} }
m.mu.Unlock()
if changed {
m.notifyChanged()
}
} }
// allWatchedIdleLocked reports whether every watched child is now // allWatchedIdleLocked reports whether every watched child is now
@@ -374,19 +400,21 @@ func (m *timerManager) allWatchedIdleLocked(t *pendingTimer) bool {
// TimerCancel removes a pending or paused timer owned by ownerID. // TimerCancel removes a pending or paused timer owned by ownerID.
func (m *timerManager) TimerCancel(ownerID, id string) error { func (m *timerManager) TimerCancel(ownerID, id string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id] t, ok := m.timers[id]
if !ok { if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
} }
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent // Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session. // MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner. // Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID { if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
} }
if t.status == timerStatusFired || t.status == timerStatusCanceled { if t.status == timerStatusFired || t.status == timerStatusCanceled {
// Cancelling a fired/cancelled timer is idempotent. // Cancelling a fired/cancelled timer is idempotent.
m.mu.Unlock()
return nil return nil
} }
if t.rt != nil { if t.rt != nil {
@@ -395,6 +423,8 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
} }
t.status = timerStatusCanceled t.status = timerStatusCanceled
delete(m.timers, id) delete(m.timers, id)
m.mu.Unlock()
m.notifyChanged()
return nil return nil
} }
@@ -402,18 +432,20 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
// keeps the timer in the registry. // keeps the timer in the registry.
func (m *timerManager) TimerPause(ownerID, id string) error { func (m *timerManager) TimerPause(ownerID, id string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id] t, ok := m.timers[id]
if !ok { if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id) return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
} }
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent // Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session. // MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner. // Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID { if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id) return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
} }
if t.status != timerStatusPending { if t.status != timerStatusPending {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id) return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
} }
if t.rt != nil { if t.rt != nil {
@@ -429,6 +461,8 @@ func (m *timerManager) TimerPause(ownerID, id string) error {
t.pausedWasMaxWait = t.kind != timerKindDelay t.pausedWasMaxWait = t.kind != timerKindDelay
} }
t.status = timerStatusPaused t.status = timerStatusPaused
m.mu.Unlock()
m.notifyChanged()
return nil return nil
} }
@@ -507,6 +541,7 @@ func (m *timerManager) TimerResume(ownerID, id string) error {
delete(m.timers, id) delete(m.timers, id)
} }
m.mu.Unlock() m.mu.Unlock()
m.notifyChanged()
if fireNow { if fireNow {
m.fireFn(owner, body, label) m.fireFn(owner, body, label)
} }
@@ -526,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,
@@ -546,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.
@@ -587,6 +632,56 @@ func (m *timerManager) activeForChild(id string) *mcp.TimerInfo {
return &info return &info
} }
const (
timerSidebarMinRefresh = 50 * time.Millisecond
timerSidebarSubsecondRefresh = 100 * time.Millisecond
)
func nextTimerSidebarLabelChange(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
if d < time.Second {
if d < timerSidebarSubsecondRefresh {
return d
}
return timerSidebarSubsecondRefresh
}
step := time.Second
if d >= time.Hour {
step = time.Hour
} else if d >= time.Minute {
step = time.Minute
}
wait := d % step
if wait <= 0 || wait < timerSidebarMinRefresh {
return timerSidebarMinRefresh
}
return wait
}
func (m *timerManager) nextSidebarRefreshAfter(now time.Time) (time.Duration, bool) {
m.mu.Lock()
defer m.mu.Unlock()
var best time.Duration
found := false
for _, t := range m.timers {
if t.status != timerStatusPending || t.firesAt.IsZero() {
continue
}
wait := nextTimerSidebarLabelChange(t.firesAt.Sub(now))
if wait <= 0 {
wait = timerSidebarMinRefresh
}
if !found || wait < best {
best = wait
found = true
}
}
return best, found
}
func isIdleState(s IdleState) bool { func isIdleState(s IdleState) bool {
return s == StateIdle return s == StateIdle
} }

View File

@@ -65,6 +65,93 @@ func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) {
return sess, mgr, rec return sess, mgr, rec
} }
func waitTimerChange(t *testing.T, mgr *timerManager) {
t.Helper()
select {
case <-mgr.changeEvents():
case <-time.After(time.Second):
t.Fatal("timed out waiting for timer change signal")
}
}
func TestNextTimerSidebarLabelChange(t *testing.T) {
tests := []struct {
name string
d time.Duration
want time.Duration
}{
{name: "minutes", d: 2*time.Minute + 10*time.Second, want: 10 * time.Second},
{name: "minute_to_seconds", d: time.Minute + 500*time.Millisecond, want: 500 * time.Millisecond},
{name: "seconds", d: 59*time.Second + 500*time.Millisecond, want: 500 * time.Millisecond},
{name: "subsecond", d: 500 * time.Millisecond, want: timerSidebarSubsecondRefresh},
{name: "nearly_done", d: 30 * time.Millisecond, want: 30 * time.Millisecond},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextTimerSidebarLabelChange(tt.d); got != tt.want {
t.Fatalf("nextTimerSidebarLabelChange(%s) = %s, want %s", tt.d, got, tt.want)
}
})
}
}
func TestTimerSidebarRefreshAfterUsesSoonestActiveBoundary(t *testing.T) {
_, mgr, _ := newTestManager(t)
now := time.Unix(123, 0)
mgr.mu.Lock()
mgr.timers["slow"] = &pendingTimer{
id: "slow",
status: timerStatusPending,
firesAt: now.Add(2*time.Minute + 10*time.Second),
}
mgr.timers["fast"] = &pendingTimer{
id: "fast",
status: timerStatusPending,
firesAt: now.Add(59*time.Second + 500*time.Millisecond),
}
mgr.timers["paused"] = &pendingTimer{
id: "paused",
status: timerStatusPaused,
firesAt: now.Add(100 * time.Millisecond),
}
mgr.mu.Unlock()
got, ok := mgr.nextSidebarRefreshAfter(now)
if !ok {
t.Fatal("nextSidebarRefreshAfter did not find active timers")
}
if got != 500*time.Millisecond {
t.Fatalf("nextSidebarRefreshAfter = %s, want 500ms", got)
}
}
func TestTimerManagerSignalsChangesForSidebar(t *testing.T) {
sess, mgr, _ := newTestManager(t)
owner := fakeChild("p_owner")
addChild(sess, owner)
id, err := mgr.TimerSet("p_owner", "x", "", 60)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerPause("p_owner", id); err != nil {
t.Fatalf("TimerPause: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerResume("p_owner", id); err != nil {
t.Fatalf("TimerResume: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("TimerCancel: %v", err)
}
waitTimerChange(t, mgr)
}
func TestTimerSetDelivers(t *testing.T) { func TestTimerSetDelivers(t *testing.T) {
sess, mgr, rec := newTestManager(t) sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner") c := fakeChild("p_owner")

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

@@ -0,0 +1,32 @@
{
"name": "restart_process_keeps_chrome",
"cols": 120,
"rows": 40,
"scripts": [
{
"name": "slow-restart",
"body": "#!/bin/sh\ncount_file=\"$XDG_RUNTIME_DIR/slow-restart-count\"\nif [ -f \"$count_file\" ]; then\n n=$(cat \"$count_file\")\nelse\n n=0\nfi\nn=$((n + 1))\nprintf '%s\\n' \"$n\" > \"$count_file\"\nprintf 'SLOW READY %s\\n' \"$n\"\ntrap 'sleep 3; exit 0' TERM\nwhile true; do sleep 1; done\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["slow-restart"], "name": "slow-restart" },
"save_as": "spawned"
},
{
"type": "mcp_call",
"method": "select_process",
"params": { "process_id": "{{spawned.process_id}}" }
},
{ "type": "wait_text", "contains": "SLOW READY 1", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "send_text", "text": "\u000brestart\r" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "slow-restart" },
{ "type": "wait_text", "contains": "SLOW READY 2", "timeout_ms": 7000 }
]
}

View File

@@ -96,10 +96,34 @@ func (s *Server) acceptLoop() {
// identity token (SPEC §10); we resolve it to a child id and stash that // identity token (SPEC §10); we resolve it to a child id and stash that
// as the caller for every subsequent tool call. // as the caller for every subsequent tool call.
func (s *Server) handleConn(conn net.Conn) { func (s *Server) handleConn(conn net.Conn) {
defer conn.Close() var writeMu sync.Mutex
var wg sync.WaitGroup
defer func() {
wg.Wait()
_ = conn.Close()
}()
r := bufio.NewReader(conn) r := bufio.NewReader(conn)
var callerID string var callerID string
writeResp := func(resp []byte) bool {
if resp == nil {
return true
}
resp = append(resp, '\n')
writeMu.Lock()
defer writeMu.Unlock()
for len(resp) > 0 {
n, err := conn.Write(resp)
if err != nil {
return false
}
if n == 0 {
return false
}
resp = resp[n:]
}
return true
}
greeting, err := r.ReadBytes('\n') greeting, err := r.ReadBytes('\n')
if err != nil { if err != nil {
@@ -115,24 +139,21 @@ func (s *Server) handleConn(conn net.Conn) {
} else { } else {
// Treat as a real request from an unknown caller. // Treat as a real request from an unknown caller.
resp := s.dispatch("", greeting) resp := s.dispatch("", greeting)
if resp != nil { if !writeResp(resp) {
resp = append(resp, '\n')
if _, werr := conn.Write(resp); werr != nil {
return return
} }
} }
}
for { for {
line, err := r.ReadBytes('\n') line, err := r.ReadBytes('\n')
if len(line) > 0 { if len(line) > 0 {
resp := s.dispatch(callerID, line) req := append([]byte(nil), line...)
if resp != nil { wg.Add(1)
resp = append(resp, '\n') go func() {
if _, werr := conn.Write(resp); werr != nil { defer wg.Done()
return resp := s.dispatch(callerID, req)
} _ = writeResp(resp)
} }()
} }
if err != nil { if err != nil {
return return

190
internal/mcp/mcp_test.go Normal file
View File

@@ -0,0 +1,190 @@
package mcp
import (
"bufio"
"encoding/json"
"fmt"
"net"
"sync"
"syscall"
"testing"
"time"
"github.com/hjbdev/patterm/internal/scratchpad"
)
func TestHandleConnDispatchesRequestsConcurrently(t *testing.T) {
serverConn, clientConn := net.Pipe()
t.Cleanup(func() { _ = clientConn.Close() })
host := &blockingToolHost{
waitEntered: make(chan struct{}),
waitRelease: make(chan struct{}),
}
s := &Server{}
s.SetHost(host)
done := make(chan struct{})
go func() {
s.handleConn(serverConn)
close(done)
}()
reader := bufio.NewReader(clientConn)
writeLine(t, clientConn, `{"patterm_identity":"ident"}`)
writeLine(t, clientConn, `{"jsonrpc":"2.0","id":1,"method":"wait_for_pattern","params":{"process_id":"p_slow","pattern":"never","timeout_seconds":300}}`)
select {
case <-host.waitEntered:
case <-time.After(time.Second):
t.Fatal("wait_for_pattern did not enter fake host")
}
writeLine(t, clientConn, `{"jsonrpc":"2.0","id":2,"method":"get_process_status","params":{"process_id":"p_fast"}}`)
fast := readJSONRPCResponse(t, clientConn, reader, time.Second)
if got := string(fast.ID); got != "2" {
t.Fatalf("first response id = %s, want 2; response=%s", got, fast.Raw)
}
if fast.Error != nil {
t.Fatalf("fast response returned error: %+v", fast.Error)
}
_ = clientConn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
if line, err := reader.ReadBytes('\n'); err == nil {
t.Fatalf("slow response arrived before release: %s", line)
}
close(host.waitRelease)
slow := readJSONRPCResponse(t, clientConn, reader, time.Second)
if got := string(slow.ID); got != "1" {
t.Fatalf("second response id = %s, want 1; response=%s", got, slow.Raw)
}
if slow.Error != nil {
t.Fatalf("slow response returned error: %+v", slow.Error)
}
_ = clientConn.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("handleConn did not exit after client close")
}
}
type jsonRPCResponse struct {
Raw string
ID json.RawMessage `json:"id"`
Result map[string]any `json:"result"`
Error *jsonRPCErrorShape `json:"error"`
}
type jsonRPCErrorShape struct {
Code int `json:"code"`
Message string `json:"message"`
}
func writeLine(t *testing.T, conn net.Conn, line string) {
t.Helper()
_ = conn.SetWriteDeadline(time.Now().Add(time.Second))
if _, err := fmt.Fprintln(conn, line); err != nil {
t.Fatalf("write %s: %v", line, err)
}
}
func readJSONRPCResponse(t *testing.T, conn net.Conn, reader *bufio.Reader, timeout time.Duration) jsonRPCResponse {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(timeout))
line, err := reader.ReadBytes('\n')
if err != nil {
t.Fatalf("read response: %v", err)
}
var resp jsonRPCResponse
resp.Raw = string(line)
if err := json.Unmarshal(line, &resp); err != nil {
t.Fatalf("parse response %s: %v", line, err)
}
return resp
}
type blockingToolHost struct {
waitEntered chan struct{}
waitRelease chan struct{}
waitOnce sync.Once
}
func (h *blockingToolHost) ResolveCallerIdentity(identity string) string { return "caller-" + identity }
func (h *blockingToolHost) CallerRole(string) CallerRole { return RoleOrchestrator }
func (h *blockingToolHost) SpawnAgent(string, SpawnAgentArgs) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) SpawnProcess(string, SpawnProcessArgs) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) StartProcess(string, string) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) RestartProcess(string, string, syscall.Signal) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) StopProcess(string, string, syscall.Signal) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) CloseProcess(string, string) error { return nil }
func (h *blockingToolHost) RenameProcess(string, string, string) error { return nil }
func (h *blockingToolHost) SelectProcess(string, string) error { return nil }
func (h *blockingToolHost) ListProcesses(string, string) []ProcessInfo { return nil }
func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) {
return ProcessStatus{ProcessInfo: ProcessInfo{ID: "p_fast", Status: "running"}}, nil
}
func (h *blockingToolHost) GetProjectStatus(string, bool) (ProjectStatus, error) {
return ProjectStatus{}, nil
}
func (h *blockingToolHost) GetProcessOutput(string, ProcessOutputArgs) (ProcessOutput, error) {
return ProcessOutput{}, nil
}
func (h *blockingToolHost) GetProcessRawOutput(string, RawOutputArgs) (RawOutput, error) {
return RawOutput{}, nil
}
func (h *blockingToolHost) SearchOutput(string, SearchOutputArgs) (SearchResult, error) {
return SearchResult{}, nil
}
func (h *blockingToolHost) WaitForPattern(string, string, string, float64, string) (bool, string, error) {
h.waitOnce.Do(func() { close(h.waitEntered) })
<-h.waitRelease
return true, "matched", nil
}
func (h *blockingToolHost) GetProcessPorts(string, string) ([]PortSighting, error) {
return nil, nil
}
func (h *blockingToolHost) SendInput(string, SendInputArgs) (SendInputResult, error) {
return SendInputResult{}, nil
}
func (h *blockingToolHost) SendMessage(string, string, string) error { return nil }
func (h *blockingToolHost) RequestHumanAttention(string, string, string) error { return nil }
func (h *blockingToolHost) TimerWait(string, float64, string) (string, error) {
return "", nil
}
func (h *blockingToolHost) TimerSet(string, TimerSetArgs) (TimerHandle, error) {
return TimerHandle{}, nil
}
func (h *blockingToolHost) TimerFireWhenIdleAny(string, TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error) {
return TimerFireWhenIdleResponse{}, nil
}
func (h *blockingToolHost) TimerFireWhenIdleAll(string, TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error) {
return TimerFireWhenIdleResponse{}, nil
}
func (h *blockingToolHost) TimerCancel(string, string) error { return nil }
func (h *blockingToolHost) TimerPause(string, string) error { return nil }
func (h *blockingToolHost) TimerResume(string, string) error { return nil }
func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) {
return nil, nil
}
func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil }
func (h *blockingToolHost) ScratchpadRead(ScratchpadReadArgs) (ScratchpadReadResult, error) {
return ScratchpadReadResult{}, nil
}
func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) {
return "", nil
}
func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil }
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil }
func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} }
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"}),
}, },
{ {
@@ -358,10 +375,19 @@ func toolCatalog() []toolDescriptor {
"content": stringProp("Text to append."), "content": stringProp("Text to append."),
}, []string{"name", "content"}), }, []string{"name", "content"}),
}, },
{
Name: "scratchpad_delete",
Description: "Delete a scratchpad entry.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
}, []string{"name"}),
},
{ {
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",
@@ -371,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
@@ -409,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 {
@@ -465,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,
@@ -498,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,12 +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
// Meta. // Meta.
WhoAmI(callerID string) WhoAmI WhoAmI(callerID string, includeTools bool) WhoAmI
Help(callerID, topic string) HelpResponse Help(callerID, topic string) HelpResponse
} }
@@ -156,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"`
@@ -171,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.
@@ -182,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.
@@ -190,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"`
@@ -244,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"`
@@ -287,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.
@@ -305,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"`
@@ -506,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)
} }
@@ -730,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 {
@@ -776,8 +824,22 @@ 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 "scratchpad_delete":
var p struct {
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ScratchpadDelete(p.Name); err != nil {
return nil, codeInternal, err.Error(), 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

@@ -352,7 +352,10 @@ func defaultAgentPresets() []*Preset {
"ready_signal": { "idle_ms": 1000 }, "ready_signal": { "idle_ms": 1000 },
"idle_detection": { "idle_detection": {
"strategy": "osc_title_stability", "strategy": "osc_title_stability",
"idle_threshold_ms": 2000 "idle_threshold_ms": 2000,
"thinking_patterns": [
"(?i)esc to interrupt"
]
}, },
"chrome_trim_hints": [ "chrome_trim_hints": [
"^OpenAI Codex", "^OpenAI Codex",

View File

@@ -27,6 +27,13 @@ func TestLoadUsesBuiltInDefaultsWithoutWritingConfig(t *testing.T) {
if claude.IdleDetection == nil || len(claude.IdleDetection.PermissionPatterns) == 0 { if claude.IdleDetection == nil || len(claude.IdleDetection.PermissionPatterns) == 0 {
t.Fatalf("built-in claude missing permission patterns: %+v", claude.IdleDetection) t.Fatalf("built-in claude missing permission patterns: %+v", claude.IdleDetection)
} }
codex := presetByName(set.Agents, "codex")
if codex == nil {
t.Fatal("missing built-in codex preset")
}
if codex.IdleDetection == nil || len(codex.IdleDetection.ThinkingPatterns) == 0 {
t.Fatalf("built-in codex missing thinking patterns: %+v", codex.IdleDetection)
}
} }
func TestLoadMergesUserOverlayIntoBuiltInPreset(t *testing.T) { func TestLoadMergesUserOverlayIntoBuiltInPreset(t *testing.T) {