Work through TODO fixes #8

Merged
harry merged 8 commits from todo-fixes into main 2026-05-25 13:13:25 +01:00
20 changed files with 617 additions and 51 deletions

View File

@@ -6,7 +6,28 @@ 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.
- Grid-mode `get_process_output` now returns whitespace-normalized
text to avoid sending padded terminal rows and repeated blank lines
over MCP.
### Fixed ### 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 - Sidebar timer indicators now repaint as their visible countdown
value changes, so labels progress from minutes to seconds without value changes, so labels progress from minutes to seconds without
waiting for unrelated terminal output or focus changes. waiting for unrelated terminal output or focus changes.

View File

@@ -1,5 +0,0 @@
- [ ] When opening a codex sub agent, the message gets input to the field, but the message is never submitted.
- This appears to be inconsistent. Sometimes it works, sometimes it doesn't. Might be because of popups on codex sub agents?
- Question: when it fails, is a Codex startup popup visible (trust/workspace, auth/model selection, permissions), or is the normal composer focused?
- Question: if the message is sitting in the composer, does pressing Enter once manually submit it, or does something else need to be dismissed first?
- Question: does this happen with short one-line prompts as well as long/multiline sub-agent instructions?

View File

@@ -514,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 ""
} }
@@ -525,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()
@@ -534,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 ""
@@ -2345,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()
@@ -2364,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()

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 (
@@ -642,8 +647,8 @@ func (c *Child) writeInput(b []byte) error {
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
@@ -659,6 +664,20 @@ func inputWritePieces(kind ChildKind, b []byte) [][]byte {
return splitOnEnter(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

@@ -3,6 +3,7 @@ package app
import ( import (
"bytes" "bytes"
"testing" "testing"
"time"
) )
func TestInputWritePiecesOnlySplitAgentEnters(t *testing.T) { func TestInputWritePiecesOnlySplitAgentEnters(t *testing.T) {
@@ -27,3 +28,63 @@ func TestInputWritePiecesOnlySplitAgentEnters(t *testing.T) {
} }
} }
} }
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"
@@ -398,7 +399,7 @@ 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 out.Content = normalizeGridText(txt)
return out, nil return out, nil
case "stream": case "stream":
b, end := c.StreamRead(sinceOffset) b, end := c.StreamRead(sinceOffset)
@@ -832,6 +833,14 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
return err return err
} }
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) mcp.WhoAmI { func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
w := mcp.WhoAmI{ w := mcp.WhoAmI{
ProcessID: callerID, ProcessID: callerID,
@@ -1010,6 +1019,30 @@ 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")
}
// 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
@@ -1091,7 +1124,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 {
@@ -1146,8 +1179,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

@@ -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,7 +267,7 @@ 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: case KindTerminal:

View File

@@ -104,3 +104,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

@@ -1,10 +1,12 @@
package app package app
import ( import (
"errors"
"io" "io"
"os" "os"
"testing" "testing"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/scratchpad"
) )
@@ -95,3 +97,41 @@ func TestDeletingLastFocusedScratchpadFocusesRunningChild(t *testing.T) {
t.Fatalf("focusedID = %q, want pid", st.focusedID) 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

@@ -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') return
if _, werr := conn.Write(resp); werr != nil {
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) (ProjectStatus, error) {
return ProjectStatus{}, nil
}
func (h *blockingToolHost) GetProcessOutput(string, string, string, int64) (ProcessOutput, error) {
return ProcessOutput{}, nil
}
func (h *blockingToolHost) GetProcessRawOutput(string, string, int64) (RawOutput, error) {
return RawOutput{}, nil
}
func (h *blockingToolHost) SearchOutput(string, string, string, string, int) (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(string) (string, string, error) {
return "", "", nil
}
func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) {
return "", nil
}
func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil }
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil }
func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} }
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }

View File

@@ -358,6 +358,13 @@ 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 the caller's identity, role, parent, project metadata, and available tools.",

View File

@@ -101,6 +101,7 @@ type ToolHost interface {
ScratchpadRead(name string) (content string, revision string, err error) ScratchpadRead(name string) (content string, revision string, err 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) WhoAmI
@@ -244,8 +245,8 @@ 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"`
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"`
WatchedIDs []string `json:"watched,omitempty"` WatchedIDs []string `json:"watched,omitempty"`
FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"` FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"`
@@ -776,6 +777,18 @@ 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 return h.WhoAmI(callerID), 0, "", nil

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) {