11 Commits

Author SHA1 Message Date
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
24 changed files with 1152 additions and 79 deletions

1
.gitignore vendored
View File

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

View File

@@ -6,6 +6,44 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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
- 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
### Added

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
// rail width, advance the pause-scroll-pause animation by marking
// 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 {
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 {
return ""
}
@@ -516,7 +532,14 @@ func (st *uiState) activeSummaryText(width int) 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 ""
}
st.settingsMu.Lock()
@@ -525,13 +548,7 @@ func (st *uiState) activeSummaryRaw() string {
if !enabled {
return ""
}
st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
return ""
}
sum := st.summaries.Summary(active)
sum := st.summaries.Summary(childID)
text := strings.TrimSpace(sum.Text)
if text == "" {
return ""
@@ -671,6 +688,20 @@ func (st *uiState) clearViewportArea() {
_, _ = 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) {
c := st.sess.FindChild(processID)
if c == nil || c.Kind != KindCommand {
@@ -687,14 +718,18 @@ func (st *uiState) restartFocusedCommand(processID string) {
st.repaintNextPTYBudget = 2
st.mu.Unlock()
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
st.repaintFocusedWithChrome()
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.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return
}
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
st.moveToViewportOrigin()
st.drawTabBar()
st.drawSidebar()
@@ -741,12 +776,7 @@ func (st *uiState) notifyAttention(childID, reason string) {
}
func (st *uiState) scratchpadsChanged() {
st.padsCacheMu.Lock()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.invalidateScratchpadsCache()
st.drawSidebar()
st.mu.Lock()
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
// the user (palette, persistence restore, or an external MCP client with
// 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() {
st.chromeCacheMu.Lock()
st.tabBarCache = ""
@@ -1433,9 +1521,10 @@ func (st *uiState) processStdin(chunk []byte) {
if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner()
// InjectAsUser splits Enter bytes onto their own
// writes so claude / codex / opencode don't treat a
// "text\r" batch as a paste.
// Agent panes split Enter bytes onto their own writes
// so claude / codex / opencode don't treat a
// "text\r" batch as a paste. Raw terminals keep paste
// bytes batched.
_ = c.InjectAsUser(forward)
if st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward)
@@ -2131,20 +2220,47 @@ func (st *uiState) handlePadDelete(name string) {
st.repaintFocused()
return
}
st.mu.Lock()
wasFocused := st.focusedPad == name
st.mu.Unlock()
if err := st.pads.Delete(name); err != nil {
st.flashError(fmt.Sprintf("delete %s: %v", name, err))
return
}
st.mu.Lock()
if st.focusedPad == name {
if wasFocused {
st.invalidateScratchpadsCache()
if entries := st.padsList(); len(entries) > 0 {
next := entries[0].Name
st.mu.Lock()
st.focusedPad = next
st.focusedID = ""
st.focusedName = next
if st.padOffsetName != next {
st.padOffset = 0
st.padOffsetName = next
}
st.mu.Unlock()
st.repaintFocusedWithChrome()
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.drawSidebar()
st.drawStatusLine()
return
}
st.mu.Unlock()
st.scratchpadsChanged()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
st.repaintFocusedWithChrome()
}
func (st *uiState) handlePadRename(oldName, newName string) {
@@ -2237,11 +2353,9 @@ func (st *uiState) handleChildRename(childID, newName string) {
st.drawStatusLine()
}
// handleChildClose removes a child entry entirely. For agents this is
// equivalent to a SIGTERM kill (the entry is ephemeral and disappears
// from the session once the PTY exits). For command processes it's
// equivalent to the MCP close_process tool: SIGKILL if alive, then
// drop the entry so it stops appearing in the switch/restart lists.
// handleChildClose removes a child entry entirely for process deletes.
// For agent Close, it terminates the PTY with escalation but preserves
// the exited pane so the user can still read the corpse.
func (st *uiState) handleChildClose(childID string, kill bool) {
if childID == "" {
st.repaintFocused()
@@ -2256,7 +2370,11 @@ func (st *uiState) handleChildClose(childID string, kill bool) {
if kill {
_ = st.sess.Close(childID, syscall.SIGKILL)
} 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.drawTabBar()
@@ -2293,8 +2411,19 @@ func (st *uiState) handleProcRestart(childID string) {
return
}
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 {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
return
}
st.repaintFocused()

View File

@@ -26,6 +26,11 @@ import (
// false positives (timestamps, exit codes, etc.).
var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`)
const (
agentInterPieceDelay = 15 * time.Millisecond
agentSubmitSettleDelay = 100 * time.Millisecond
)
type ChildStatus string
const (
@@ -625,25 +630,25 @@ func (c *Child) InjectAsOrchestrator(b []byte) error {
}
// writeInput is the shared PTY write path used by both injection
// flavours. Each Enter byte (CR or LF) is split onto its own write
// with a brief delay so TUI agents with paste-detection (claude,
// flavours. Agent panes split each Enter byte (CR or LF) onto its own
// write with a brief delay so TUI agents with paste-detection (claude,
// codex, opencode) don't coalesce a trailing CR into the text that
// preceded it. Without the split, `pty.Write([]byte("hello\r"))`
// arrives at the agent as one read() and gets treated as multi-line
// pasted content rather than "key Enter".
// preceded it. Raw terminals and command panes receive the original
// byte stream in one write; otherwise a multiline paste pays the agent
// workaround's delay once per line.
func (c *Child) writeInput(b []byte) error {
pty := c.PTY()
if pty == nil {
return errors.New("child has no pty")
}
pieces := splitOnEnter(b)
pieces := inputWritePieces(c.Kind, b)
if len(pieces) <= 1 {
_, err := pty.Write(b)
return err
}
for i, piece := range pieces {
if i > 0 {
time.Sleep(15 * time.Millisecond)
if delay := pieceWriteDelay(i, len(pieces), piece); delay > 0 {
time.Sleep(delay)
}
if _, err := pty.Write(piece); err != nil {
return err
@@ -652,6 +657,27 @@ func (c *Child) writeInput(b []byte) error {
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 {
var buf [12]byte
_, _ = 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"
"syscall"
"time"
"unicode"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
@@ -398,7 +399,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
}
out.Content = txt
out.Content = normalizeGridText(txt)
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
@@ -832,6 +833,14 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
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 {
w := mcp.WhoAmI{
ProcessID: callerID,
@@ -1010,6 +1019,30 @@ func stripANSI(s string) string {
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
// string conversion and the regex DFA — useful when the caller will
// 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",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"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",
}
if role == mcp.RoleOrchestrator {
@@ -1146,8 +1179,8 @@ func helpFor(topic string) mcp.HelpResponse {
case "scratchpads":
return mcp.HelpResponse{
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.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"},
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", "scratchpad_delete"},
}
case "timers":
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) {
cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStatus,

View File

@@ -267,9 +267,18 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
out = append(out,
paletteItem{label: "Rename", hint: "rename agent · " + name,
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},
)
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:
out = append(out,
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) {
// Two children so there's still a non-focused switch entry to compare
// against (the focused child is suppressed from the Open section).

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

@@ -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
}
// 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
// if it collides with an existing entry. Caller holds s.mu.
func (s *Session) mintUniqueIDLocked() string {

View File

@@ -1,6 +1,7 @@
package app
import (
"strings"
"syscall"
"testing"
"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) {
t.Helper()
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) {
sess := NewSession(t.TempDir(), "test")
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
type tabRect struct {
childID string
startCol int
width int
label string
@@ -66,8 +67,6 @@ func (st *uiState) drawTabBar() {
glyphStyle string
active bool
}
activeTab := -1
// Reserve space at the right edge for "+ new". If there are too
// many tabs to fit even at minTabWidth, drop tabs from the right
// until they do. The current focus stays visible.
@@ -139,6 +138,7 @@ func (st *uiState) drawTabBar() {
labelW = utf8.RuneCountInString(label)
}
tabs = append(tabs, tabRect{
childID: c.ID,
startCol: col,
width: w,
label: label,
@@ -146,9 +146,6 @@ func (st *uiState) drawTabBar() {
glyphStyle: glyphStyle,
active: active,
})
if tabs[len(tabs)-1].active {
activeTab = len(tabs) - 1
}
col += w
}
}
@@ -224,10 +221,9 @@ func (st *uiState) drawTabBar() {
hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset)
}
if activeTab >= 0 {
tab := tabs[activeTab]
for _, tab := range tabs {
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)
}
}

View File

@@ -55,9 +55,10 @@ type pendingTimer struct {
type timerManager struct {
sess *Session
mu sync.Mutex
nextID int
timers map[string]*pendingTimer
mu sync.Mutex
nextID int
timers map[string]*pendingTimer
changes chan struct{}
// fireFn is the callback used to deliver the body to the owning
// process. Decoupled so tests can substitute a recorder. Defaults
@@ -67,13 +68,25 @@ type timerManager struct {
func newTimerManager(sess *Session) *timerManager {
m := &timerManager{
sess: sess,
timers: make(map[string]*pendingTimer),
sess: sess,
timers: make(map[string]*pendingTimer),
changes: make(chan struct{}, 1),
}
m.fireFn = defaultFireFn
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) {
if owner == nil || !owner.IsLive() {
return
@@ -121,6 +134,7 @@ func (m *timerManager) TimerSet(ownerID string, body, label string, seconds floa
m.timers[id] = t
m.mu.Unlock()
t.rt = time.AfterFunc(d, func() { m.fireDelay(id) })
m.notifyChanged()
return id, nil
}
@@ -136,6 +150,7 @@ func (m *timerManager) fireDelay(id string) {
body, label := t.body, t.label
delete(m.timers, id)
m.mu.Unlock()
m.notifyChanged()
m.fireFn(owner, body, label)
}
@@ -214,6 +229,7 @@ func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, l
}
m.timers[id] = t
m.mu.Unlock()
m.notifyChanged()
resp.ID = id
resp.Status = "pending"
return resp, nil
@@ -231,6 +247,7 @@ func (m *timerManager) fireIdleMaxWait(id string) {
body, label := t.body, t.label
delete(m.timers, id)
m.mu.Unlock()
m.notifyChanged()
m.fireFn(owner, body, label)
}
@@ -291,6 +308,9 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
delete(m.timers, id)
}
m.mu.Unlock()
if len(firedIDs) > 0 {
m.notifyChanged()
}
for _, f := range fires {
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.
func (m *timerManager) onChildClosed(childID string) {
m.mu.Lock()
defer m.mu.Unlock()
changed := false
for id, t := range m.timers {
if t.ownerID == childID {
if t.rt != nil {
@@ -329,6 +349,7 @@ func (m *timerManager) onChildClosed(childID string) {
}
t.status = timerStatusCanceled
delete(m.timers, id)
changed = true
continue
}
if !contains(t.watched, childID) {
@@ -344,6 +365,7 @@ func (m *timerManager) onChildClosed(childID string) {
if t.idleBaseline != nil {
delete(t.idleBaseline, childID)
}
changed = true
if len(t.watched) == 0 {
if t.rt != nil {
t.rt.Stop()
@@ -353,6 +375,10 @@ func (m *timerManager) onChildClosed(childID string) {
delete(m.timers, id)
}
}
m.mu.Unlock()
if changed {
m.notifyChanged()
}
}
// 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.
func (m *timerManager) TimerCancel(ownerID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id]
if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
}
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status == timerStatusFired || t.status == timerStatusCanceled {
// Cancelling a fired/cancelled timer is idempotent.
m.mu.Unlock()
return nil
}
if t.rt != nil {
@@ -395,6 +423,8 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
}
t.status = timerStatusCanceled
delete(m.timers, id)
m.mu.Unlock()
m.notifyChanged()
return nil
}
@@ -402,18 +432,20 @@ func (m *timerManager) TimerCancel(ownerID, id string) error {
// keeps the timer in the registry.
func (m *timerManager) TimerPause(ownerID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id]
if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
}
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status != timerStatusPending {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
}
if t.rt != nil {
@@ -429,6 +461,8 @@ func (m *timerManager) TimerPause(ownerID, id string) error {
t.pausedWasMaxWait = t.kind != timerKindDelay
}
t.status = timerStatusPaused
m.mu.Unlock()
m.notifyChanged()
return nil
}
@@ -507,6 +541,7 @@ func (m *timerManager) TimerResume(ownerID, id string) error {
delete(m.timers, id)
}
m.mu.Unlock()
m.notifyChanged()
if fireNow {
m.fireFn(owner, body, label)
}
@@ -587,6 +622,56 @@ func (m *timerManager) activeForChild(id string) *mcp.TimerInfo {
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 {
return s == StateIdle
}

View File

@@ -65,6 +65,93 @@ func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) {
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) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")

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
// as the caller for every subsequent tool call.
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)
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')
if err != nil {
@@ -115,24 +139,21 @@ func (s *Server) handleConn(conn net.Conn) {
} else {
// Treat as a real request from an unknown caller.
resp := s.dispatch("", greeting)
if resp != nil {
resp = append(resp, '\n')
if _, werr := conn.Write(resp); werr != nil {
return
}
if !writeResp(resp) {
return
}
}
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
resp := s.dispatch(callerID, line)
if resp != nil {
resp = append(resp, '\n')
if _, werr := conn.Write(resp); werr != nil {
return
}
}
req := append([]byte(nil), line...)
wg.Add(1)
go func() {
defer wg.Done()
resp := s.dispatch(callerID, req)
_ = writeResp(resp)
}()
}
if err != nil {
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."),
}, []string{"name", "content"}),
},
{
Name: "scratchpad_delete",
Description: "Delete a scratchpad entry.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
}, []string{"name"}),
},
{
Name: "whoami",
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)
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error
ScratchpadDelete(name string) error
// Meta.
WhoAmI(callerID string) WhoAmI
@@ -244,8 +245,8 @@ type TimerInfo struct {
ID string `json:"timer_id"`
Label string `json:"label,omitempty"`
Body string `json:"body,omitempty"`
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused"
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused"
OwnerID string `json:"owner_process_id"`
WatchedIDs []string `json:"watched,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
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":
return h.WhoAmI(callerID), 0, "", nil

View File

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

View File

@@ -27,6 +27,13 @@ func TestLoadUsesBuiltInDefaultsWithoutWritingConfig(t *testing.T) {
if claude.IdleDetection == nil || len(claude.IdleDetection.PermissionPatterns) == 0 {
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) {