23 Commits
v0.0.5 ... main

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
fe25fcf043 Release v0.0.7
All checks were successful
release / build-linux-amd64 (push) Successful in 12m5s
2026-05-18 13:02:46 +01:00
2fa00ad510 Show idle state in the top tab bar
Each agent tab now prefixes its label with the same one-rune idle
indicator the sidebar uses (✕ error, ? permission, ◐ thinking, ○ idle,
● working), so the state of every open agent is visible without
opening or focusing each tab. Tab redraws now fire on idle-state
changes in addition to sidebar redraws.
2026-05-18 13:02:35 +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
34b41be1df Cancel pending timers when a child is closed
Stale timer bodies were re-delivered to the orchestrator pane after
the parent had already processed the sub-agent's reply and called
close_process. The timer registry held no link to the child
lifecycle, so timers owned by or watching the closed child lingered
until something triggered a fire — e.g. a trailing classifier tick
for the now-removed child.

Add an OnChildClosed hook to ChildEventListener, emit it from
Session.Close (and the terminal-corpse path in reapChild), and have
the timer manager prune the registry: cancel timers owned by the
closed child; remove the closed child from each timer's watched
list (cancel the timer outright when watched empties).

Natural exit deliberately does not route through this hook — the
classifier already emits an idle transition on exit which delivers
any legitimate "fire when sub-agent finishes" semantics exactly
once; cancelling on exit would swallow that.
2026-05-18 12:37:32 +01:00
de60b93bc6 Use built-in agent preset defaults 2026-05-18 11:28:00 +01:00
67b994f629 Clean up auto-summary settings menu 2026-05-18 10:17:25 +01:00
f10598601f Finish settings TODO cleanup 2026-05-18 10:05:26 +01:00
cadd4c8f64 Release v0.0.6
All checks were successful
release / build-linux-amd64 (push) Successful in 11m48s
2026-05-15 21:55:09 +01:00
98d1c059cf summarizer tweaks 2026-05-15 21:54:14 +01:00
cf65d5d707 Wrap toast bodies, slim the dismiss hint, and stop flicker
Toasts now render three content rows with word-wrapped bodies. The
in-toast "Ctrl-N · N more" hint is replaced by a short
"Ctrl-N · dismiss" entry on the status strip that only appears
while a notification is live.

The box stops flickering while the focused child repaints its TUI:
the overlay is stitched onto the per-chunk PTY write under outMu
and bracketed by DECSET 2026 so supporting terminals buffer the
child's redraw and the box paint into a single frame.
2026-05-15 21:24:18 +01:00
43 changed files with 3205 additions and 534 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,111 @@ 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.
- `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
### Added
- The top tab bar now prefixes each agent tab's label with its
idle-state glyph (✕ error, ? permission, ◐ thinking, ○ idle, ●
working), matching the sidebar's vocabulary so the state of every
open agent is visible without opening or focusing each tab.
### Changed
- Built-in agent presets (`claude`, `codex`, `opencode`) now live in
memory and user preset files merge over them by name instead of
patterm writing default preset files into `$XDG_CONFIG_HOME`. Add
`"disabled": true` in a matching user preset to hide a built-in.
- Generated MCP config files for agent launches now live under the
runtime agent directory instead of `$XDG_CONFIG_HOME/patterm/mcp`.
- Auto-summarization settings now save as soon as a changed row is
applied, including cadence/provider/toggle changes and model edits,
without requiring a separate save step.
- The Agents / Auto-summarization settings screen no longer shows
explicit Save, Cancel, or Back rows, and its footer copy no longer
describes a separate save/cancel flow.
- Auto-summarization setting rows now visually separate grey labels
from regular-colour values.
- The active-thread summary in the tab bar is now constrained to the
active tab's width instead of spanning the whole top row.
- Sidebar summary text now wraps from the full summary text instead of
using an ellipsized single-line value.
### Fixed
- Claude permission prompts are now detected from the rendered pane as
well as the recent output tail, so the sidebar marks the pane as
waiting for permission even while `Calling patterm...` continues to
repaint.
- Removed the redundant "Back to Settings" row from the
Agents / Auto-summarization settings screen.
- Pending `timer_*` entries are now cancelled when their owning or
watched child is closed via `close_process`, preventing stale
timer bodies from being re-delivered to the orchestrator pane
after the work has already been handled.
## [0.0.6] - 2026-05-15
### Changed
- Toast notifications now reserve three content rows and word-wrap
the message body inside the box, replacing the previous
single-line+ellipsis layout. The `Ctrl-N · N more` inline hint is
gone; instead the host status strip surfaces a `Ctrl-N · dismiss`
hint, shown only while a notification is on screen so the chord
doesn't advertise itself when it has nothing to dismiss.
### Fixed
- Auto-summary no longer fails immediately with `codex summarizer:
error: unexpected argument '--ask-for-approval' found`. The codex
CLI dropped that flag; we now rely on `--sandbox read-only` (which
already implies no approvals) instead of passing it.
- Toast box no longer flickers / half-erases while the focused
child (claude, codex, opencode, etc.) repaints its TUI. The
overlay is now stitched onto the end of the per-chunk PTY write
under `outMu`, and wrapped in DECSET 2026 (synchronized output)
brackets so terminals that support it batch the child's redraw +
the box paint into a single frame instead of racing cell-by-cell.
## [0.0.5] - 2026-05-15
### Changed

23
SPEC.md
View File

@@ -39,7 +39,7 @@ The tool is one Go process that owns: the TUI, all PTYs, vt-emulated grids, sess
## 3. Project state layout
Scratchpads (user data) live under `$XDG_DATA_HOME`; presets and config live under `$XDG_CONFIG_HOME`.
Scratchpads (user data) live under `$XDG_DATA_HOME`; user-authored preset overlays and config live under `$XDG_CONFIG_HOME`.
```
$XDG_DATA_HOME/patterm/
@@ -53,12 +53,12 @@ $XDG_DATA_HOME/patterm/
└── <agent-written>.md
$XDG_CONFIG_HOME/patterm/
├── config.json # global settings (theme, default keymap, etc.)
├── settings.json # global settings, written only after the user changes settings
└── presets/
├── agents/
│ ├── claude.json # ships as default
│ ├── codex.json # ships as default
│ ├── opencode.json # ships as default
│ ├── claude.json # optional overlay for built-in claude
│ ├── codex.json # optional overlay for built-in codex
│ ├── opencode.json # optional overlay for built-in opencode
│ └── <user-defined>.json
└── processes/
├── dev.json # e.g. { "name": "bun run dev", "argv": ["bun", "run", "dev"] }
@@ -66,7 +66,7 @@ $XDG_CONFIG_HOME/patterm/
└── <user-defined>.json
```
Both preset directories are scanned at startup; every file found becomes a palette entry ("Spawn agent: claude", "Run process: bun run dev", …). Presets are project-agnostic in v1 — the same set is available in every project. Per-project overrides can be added later.
patterm always has built-in agent presets for `claude`, `codex`, and `opencode`. User preset files are scanned at startup and merged into matching built-ins by `name`, or added as standalone custom presets when the name is new. A matching file with `"disabled": true` hides a built-in. Startup does not write default preset files. Presets are project-agnostic in v1 — the same set is available in every project. Per-project overrides can be added later.
Project key = `sha256(realpath(project_dir))[:16]`. Used only as a scratchpad directory name — there is no daemon to look up.
@@ -121,7 +121,7 @@ Scratchpads and command-preset trust grants persist across runs. Sessions and ch
Almost all application functions are driven through a single command palette opened with `Ctrl-K`. The palette is a fuzzy-searchable list of commands, scoped to whatever makes sense for the current focus. Two kinds of entries appear:
- **Built-in commands** — "Switch to session…", "Focus pane…", "Take input control", "Release control to orchestrator", "Open scratchpad…", "Kill child…", "Quit", etc.
- **Preset commands** — one entry per file under `$XDG_CONFIG_HOME/patterm/presets/`. Agent presets surface as "Spawn agent: codex" / "Spawn agent: claude" / …; process presets surface as "Run process: bun run dev" / "Run process: vitest" / …. The label comes from the preset's `name` field; the action is "launch this preset into a new pane."
- **Preset commands** — one entry per built-in or user-defined preset. Agent presets surface as "Spawn agent: codex" / "Spawn agent: claude" / …; process presets surface as "Run process: bun run dev" / "Run process: vitest" / …. The label comes from the preset's `name` field; the action is "launch this preset into a new pane."
Selecting a preset either launches it immediately (no required args) or opens a sub-palette for optional args — namely an **initial prompt** (agent presets only), which patterm injects into the spawned PTY's input after the agent is ready (§8). The orchestrator equivalent of this — `spawn_agent` / `spawn_process` MCP tools — uses the exact same machinery: pick a preset by name, optionally supply an initial prompt, patterm handles the rest.
@@ -365,11 +365,11 @@ Risks acknowledged: the orchestrator's reading of the prompt is a vision/parsing
## 10. Presets
Presets are user-editable JSON files that describe how to launch something. patterm itself has no hard-coded agent or process types — every spawnable thing is a preset. Two flavours:
Presets describe how to launch something. patterm has built-in defaults for common agent CLIs, and user-editable JSON files can override, disable, or add presets. Two flavours:
### Agent presets
`$XDG_CONFIG_HOME/patterm/presets/agents/<name>.json`. Launches a vendor LLM CLI with MCP wired up and the conversation-protocol addendum injected.
Built-in agent presets launch vendor LLM CLIs with MCP wired up and the conversation-protocol addendum injected. `$XDG_CONFIG_HOME/patterm/presets/agents/<name>.json` can overlay a built-in by `name` or define a new agent preset.
| Field | Purpose |
|---|---|
@@ -377,17 +377,18 @@ Presets are user-editable JSON files that describe how to launch something. patt
| `argv` | Full launch argv (e.g. `["claude"]`, `["codex", "--no-tui-banner"]`) |
| `env` | Env vars to set (merged over inherited env) |
| `working_dir` | Defaults to the project root |
| `disabled` | If `true`, hides a built-in preset with the same `name` |
| `mcp_injection` | How to point this CLI at patterm's stdio proxy. One of: `{ "kind": "flag", "flag": "--mcp-config", "config_path": "..." }`, `{ "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" }`, `{ "kind": "env_var", "var": "MCP_CONFIG_PATH" }` |
| `ready_signal` | How to detect the TUI is ready (default: 1s idle after launch). Override per-CLI if needed. |
| `chrome_trim_hints` | Optional regexes / row ranges for stripping vendor chrome in grid reads |
Default presets shipped: `claude`, `codex`, `opencode`. Authoring these is per-vendor research — each CLI has its own MCP config conventions, ready states, and TUI chrome. Users can copy and edit them, or add new ones (e.g. a second `claude` preset that launches with a specific model or system prompt file).
Built-in presets: `claude`, `codex`, `opencode`. Authoring these is per-vendor research — each CLI has its own MCP config conventions, ready states, idle detection, and TUI chrome. Users can add small overlay files for built-ins, disable built-ins, or add new presets (e.g. a second `claude-sonnet` preset that launches with a specific model or system prompt file).
MCP config flow: at startup, for each agent preset, patterm renders a small JSON pointing at its own `mcp-stdio` proxy subcommand (`patterm mcp-stdio --socket <pid-sock> --identity <token>`) into a per-preset temp file. The launch then uses the preset's `mcp_injection` strategy to hand that path to the CLI. The user's global vendor config is never mutated.
### Process presets
`$XDG_CONFIG_HOME/patterm/presets/processes/<name>.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt.
`$XDG_CONFIG_HOME/patterm/presets/processes/<name>.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt. There are no built-in process presets.
| Field | Purpose |
|---|---|

View File

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

View File

@@ -1,61 +0,0 @@
claude + new │ Processes
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────│ ─────────────────────────
- abc1234 if no tag exists yet
4. Wire version into the release workflow
Update .gitea/workflows/release.yml lines 31-35 to inject the pushed tag:
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o dist/patterm-${{ github.ref_name }}-linux-amd64 \
./cmd/patterm
github.ref_name is the tag name (e.g. v0.0.1) because the workflow only
triggers on tags: ['v*'].
5. Update inline doc comment
cmd/patterm/main.go header comment (lines 5-11) — add the --version form
to the usage block. SPEC.md/CLAUDE.md already use --, no change needed there.
Out of scope
- Surfacing version in MCP whoami (the hardcoded "version": "0.1.0" in
internal/mcp/protocol.go:27 is the MCP protocol version, not the patterm
binary version — leave it).
- Renaming any existing flags.
- Adding short forms like -p for --project.
Critical files
- cmd/patterm/main.go — import swap, --version wiring, version var, header comment
- cmd/patterm/debug_harness.go — import swap
- Makefile lines 38-39 — VERSION var + ldflags
- .gitea/workflows/release.yml lines 31-35 — ldflags
- go.mod / go.sum — add github.com/spf13/pflag
Verification
1. go build -o ./bin/patterm ./cmd/patterm (without Makefile) → still builds, version reports dev.
2. make patterm → ./bin/patterm --version prints patterm v0.0.1 (commit <sha>, built <date>).
3. ./bin/patterm -h → help text shows --project string and --version lines.
4. ./bin/patterm -project /tmp → pflag rejects with usage error (confirms -- is enforced).
5. ./bin/patterm --project /tmp → starts normally.
6. ./bin/patterm mcp-stdio --socket /tmp/s --identity x → existing path still works (will fail to connect, but should parse flags fine).
7. ./bin/patterm debug-harness --scenario internal/harness/scenarios/spawn_process_via_palette.json → harness still runs.
8. go test ./... and go test ./internal/harness/... — both green.
9. Push a temporary tag locally and inspect git describe output; confirm release workflow's ${{ github.ref_name }} substitution matches the tag.
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Claude has written up a plan and is ready to execute. Would you like to proceed?
1. Yes, and use auto mode
2. Yes, manually approve edits
3. No, refine with Ultraplan on Claude Code on the web
4. Tell Claude what to change
shift+tab to approve with this feedback
ctrl-g to edit in VS Code · ~/.claude/plans/flags-in-this-project-vectorized-gosling.md
claude · you have control Ctrl-A/D · tabs · Ctrl-W/S · tree · Ctrl-K · palette

View File

@@ -187,9 +187,7 @@ func Run(ctx context.Context, opts Options) error {
}, func(_ string, result summaryState) {
if result.Error != "" {
st.flashError(fmt.Sprintf("summary: %v", result.Error))
return
}
st.flashTransient("summary updated")
})
sess.SetMetrics(metrics)
host.attention = st
@@ -328,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
@@ -507,7 +514,32 @@ func (st *uiState) dbgf(format string, args ...any) {
}
func (st *uiState) activeSummaryText(width int) string {
if width <= 0 || st.summaries == nil {
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 ""
}
if visibleLen(text) > width {
text = clipRunes(text, width-1) + "…"
}
return text
}
func (st *uiState) activeSummaryRaw() string {
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()
@@ -516,20 +548,11 @@ func (st *uiState) activeSummaryText(width int) 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 ""
}
if visibleLen(text) > width {
text = clipRunes(text, width-1) + "…"
}
return text
}
@@ -665,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 {
@@ -681,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()
@@ -735,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
@@ -750,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
@@ -823,14 +868,21 @@ func (st *uiState) OnChildSpawned(c *Child) {
st.drawStatusLine()
}
// OnChildStateChanged repaints the sidebar whenever a child's
// idle-state badge flips. Cheap — the badge is the only chrome that
// reflects state today, and drawSidebar bails when the cached frame
// hasn't changed.
// OnChildStateChanged repaints the sidebar and tab bar whenever a
// child's idle-state badge flips. Cheap — both draws bail when the
// cached frame hasn't changed.
func (st *uiState) OnChildStateChanged(string, IdleState) {
st.drawTabBar()
st.drawSidebar()
}
// OnChildClosed is the explicit-removal hook (close_process or the
// terminal-corpse cleanup in reapChild). The UI already reflects
// removals via the OnChildExited path and the children-map view, so
// this is a no-op here — the timerManager is the consumer that
// cares.
func (st *uiState) OnChildClosed(string) {}
// OnChildExited drops focus and shows the empty state if it was the
// focused child.
func (st *uiState) OnChildExited(c *Child) {
@@ -969,14 +1021,19 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.metrics.recordRender(time.Since(rstart))
}
}
// One write covers the autowrap-disable prelude, the chunk, and the
// autowrap-restore postlude — three syscalls collapsed into one
// under outMu. The three sequences were already emitted atomically
// under the lock; coalescing just halves the syscall count.
wrapped := make([]byte, 0, len(out)+10)
// One write covers the autowrap-disable prelude, the chunk, the
// autowrap-restore postlude, and (when a toast is up) the toast
// overlay — four syscalls collapsed into one under outMu. The
// sequences were already emitted atomically under the lock;
// coalescing just halves the syscall count and makes claude's
// continuous redraws + our toast layer land in the same frame so
// the box doesn't flicker as the child paints over its cells.
overlay := st.toastOverlayBytes()
wrapped := make([]byte, 0, len(out)+len(overlay)+10)
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
wrapped = append(wrapped, overlay...)
var wstart time.Time
if st.metrics != nil {
wstart = time.Now()
@@ -1125,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 = ""
@@ -1219,6 +1325,12 @@ func (st *uiState) drawStatusLine() {
hints = append(hints, "Ctrl-R · restart")
}
}
// Surface the toast-dismiss chord only while a notification is on
// screen — the hint is noise otherwise, and Ctrl-N falls through
// to the focused PTY when the stack is empty.
if st.toasts.length() > 0 {
hints = append(hints, "Ctrl-N · dismiss")
}
right := strings.Join(hints, " · ")
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
hints = hints[1:]
@@ -1409,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)
@@ -1617,6 +1730,11 @@ func (st *uiState) processStdin(chunk []byte) {
adv = 1
}
i += adv
if action.kind == "settings-save" {
st.applySettingsAction(action)
st.renderPaletteLocked()
continue
}
if done {
a := action
pendingAction = &a
@@ -2026,13 +2144,6 @@ func (st *uiState) closePalette(action paletteAction) {
case "proc-restart":
st.handleProcRestart(action.childID)
case "settings-close":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "settings-test":
st.applySettingsAction(action)
restoreView()
@@ -2109,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) {
@@ -2215,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()
@@ -2234,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()
@@ -2271,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()

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.).
var portRegex = regexp.MustCompile(`https?://[^\s:/]+:(\d{2,5})(?:/[^\s]*)?`)
const (
agentInterPieceDelay = 15 * time.Millisecond
agentSubmitSettleDelay = 100 * time.Millisecond
)
type ChildStatus string
const (
@@ -527,6 +532,12 @@ func (c *Child) StreamRead(since int64) ([]byte, int64) {
return out, end
}
func (c *Child) StreamOffset() int64 {
c.ringMu.Lock()
defer c.ringMu.Unlock()
return c.ringWrites
}
func (c *Child) signal(sig syscall.Signal) error {
pty := c.PTY()
if pty == nil {
@@ -625,25 +636,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 +663,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

@@ -50,8 +50,14 @@ func (s *Session) classifyOne(c *Child) {
idleMS := c.IdleMS()
titleIdleMS := c.TitleIdleMS()
title := c.Title()
tail := c.tailBytes(classifierTailBytes)
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail)
tail := stripANSIBytes(nil, c.tailBytes(classifierTailBytes))
var screen []byte
if em := c.Emulator(); em != nil {
if txt, err := em.ScreenText(); err == nil {
screen = []byte(txt)
}
}
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail, screen)
if c.setIdleState(state, reason) {
s.emitStateChanged(c.ID, state)
}

View File

@@ -111,6 +111,13 @@ func (d *debugCapture) OnChildStateChanged(id string, state IdleState) {
})
}
func (d *debugCapture) OnChildClosed(id string) {
d.writeEvent("child_closed", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": id,
})
}
func (d *debugCapture) OnPTYOut(childID string, chunk []byte) {
if len(chunk) == 0 {
return

View File

@@ -7,6 +7,7 @@ import (
"sync"
"syscall"
"time"
"unicode"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
@@ -64,6 +65,17 @@ type toolHost struct {
timers *timerManager
}
const (
defaultMCPContentBytes = 12_000
maxMCPContentBytes = 65_536
defaultMCPCanonicalLines = 120
maxMCPCanonicalLines = 500
defaultMCPTailBytes = 8_000
defaultScratchpadReadBytes = 12_000
defaultSearchLineBytes = 2_000
maxSearchMatches = 50
)
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
h := &toolHost{
sess: sess,
@@ -86,10 +98,10 @@ func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, pres
return h
}
// timerListenerAdapter forwards OnChildStateChanged into the timer
// manager and ignores the other ChildEventListener methods. The
// session's listener API is by-interface, so we wrap the manager
// rather than make it implement the full surface.
// timerListenerAdapter forwards OnChildStateChanged and OnChildClosed
// into the timer manager and ignores the other ChildEventListener
// methods. The session's listener API is by-interface, so we wrap
// the manager rather than make it implement the full surface.
type timerListenerAdapter struct{ m *timerManager }
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
@@ -98,6 +110,9 @@ func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
a.m.onChildStateChanged(id, st)
}
func (a timerListenerAdapter) OnChildClosed(id string) {
a.m.onChildClosed(id)
}
func (h *toolHost) SetSize(cols, rows uint16) {
h.sizeMu.Lock()
@@ -349,8 +364,8 @@ func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStat
return st, nil
}
func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
caller := h.WhoAmI(callerID)
func (h *toolHost) GetProjectStatus(callerID string, includeTools bool) (mcp.ProjectStatus, error) {
caller := h.WhoAmI(callerID, includeTools)
processes := h.ListProcesses(callerID, "")
pads, _ := h.pads.List()
return mcp.ProjectStatus{
@@ -361,27 +376,48 @@ func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error)
}, nil
}
func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
func (h *toolHost) GetProcessOutput(callerID string, args mcp.ProcessOutputArgs) (mcp.ProcessOutput, error) {
processID, mode, sinceOffset := args.ProcessID, args.Mode, args.SinceOffset
c := h.sess.FindChild(processID)
if c == nil {
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
if mode == "" {
mode = "grid"
}
if args.Raw {
b, end := c.StreamRead(sinceOffset)
content, contentBytes, truncated, truncatedBytes := capBytesTail(b, capLimit(args.MaxBytes, defaultMCPContentBytes))
return mcp.ProcessOutput{
Content: content,
Mode: "stream",
NewOffset: end,
Status: string(c.Status()),
ContentBytes: contentBytes,
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil
}
out := mcp.ProcessOutput{
Mode: mode,
IdleMS: c.IdleMS(),
Status: string(c.Status()),
ScreenVersion: c.ScreenVersion(),
Canonicalized: true,
}
if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil {
out.ActiveScreen = activeScreenName(sc)
if args.IncludeMeta {
out.IdleMS = c.IdleMS()
out.ScreenVersion = c.ScreenVersion()
if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil {
out.ActiveScreen = activeScreenName(sc)
}
if cur, err := em.Cursor(); err == nil {
out.Cursor = &mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
}
cols, rows := em.Size()
out.Cols, out.Rows = int(cols), int(rows)
}
if cur, err := em.Cursor(); err == nil {
out.Cursor = mcp.Cursor{X: int(cur.Col), Y: int(cur.Row)}
}
cols, rows := em.Size()
out.Cols, out.Rows = int(cols), int(rows)
}
maxLines := canonicalLineLimit(args.MaxLines)
switch mode {
case "grid":
em := c.Emulator()
@@ -395,11 +431,21 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
}
out.Content = txt
content, lineTruncated, lineDroppedBytes := canonicalizeTerminalText(txt, maxLines)
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextMiddle(content, capLimit(args.MaxBytes, defaultMCPContentBytes))
if lineTruncated {
out.Truncated = true
out.TruncatedBytes += lineDroppedBytes
}
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
out.Content = string(stripANSIBytes(nil, b))
content, lineTruncated, lineDroppedBytes := canonicalizeTerminalText(string(b), maxLines)
out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextTail(content, capLimit(args.MaxBytes, defaultMCPContentBytes))
if lineTruncated {
out.Truncated = true
out.TruncatedBytes += lineDroppedBytes
}
out.NewOffset = end
return out, nil
default:
@@ -407,34 +453,46 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
}
}
func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
c := h.sess.FindChild(processID)
func (h *toolHost) GetProcessRawOutput(callerID string, args mcp.RawOutputArgs) (mcp.RawOutput, error) {
c := h.sess.FindChild(args.ProcessID)
if c == nil {
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
b, end := c.StreamRead(sinceOffset)
b, end := c.StreamRead(args.SinceOffset)
content, contentBytes, truncated, truncatedBytes := capBytesTail(b, capLimit(args.MaxBytes, defaultMCPContentBytes))
return mcp.RawOutput{
Content: string(b),
NewOffset: end,
Status: string(c.Status()),
Content: content,
NewOffset: end,
Status: string(c.Status()),
ContentBytes: contentBytes,
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil
}
func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
c := h.sess.FindChild(processID)
func (h *toolHost) SearchOutput(callerID string, args mcp.SearchOutputArgs) (mcp.SearchResult, error) {
c := h.sess.FindChild(args.ProcessID)
if c == nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
re, err := regexp.Compile(pattern)
re, err := regexp.Compile(args.Pattern)
if err != nil {
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
b, _ := c.StreamRead(0)
if kind == "rendered" {
if args.Kind == "rendered" {
b = stripANSIBytes(nil, b)
}
text := string(b)
lines := strings.Split(text, "\n")
limit := args.Limit
if limit <= 0 {
limit = 10
}
if limit > maxSearchMatches {
limit = maxSearchMatches
}
lineLimit := capLimit(args.MaxBytes, defaultSearchLineBytes)
matches := make([]mcp.SearchMatch, 0, limit)
truncated := false
for i, line := range lines {
@@ -443,6 +501,8 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
truncated = true
break
}
line, _, lineTruncated, _ := capTextTail(line, lineLimit)
truncated = truncated || lineTruncated
matches = append(matches, mcp.SearchMatch{LineNo: i + 1, Text: line})
}
}
@@ -553,6 +613,7 @@ func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
}
}
func (n *chunkNotifier) OnChildStateChanged(string, IdleState) {}
func (n *chunkNotifier) OnChildClosed(string) {}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
@@ -583,6 +644,7 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
if err != nil {
return mcp.SendInputResult{}, err
}
tailSince := c.StreamOffset()
if err := c.InjectAsOrchestrator(payload); err != nil {
return mcp.SendInputResult{}, err
}
@@ -594,7 +656,12 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI
}
if mode != "none" {
time.Sleep(time.Duration(args.WaitMS) * time.Millisecond)
tail, err := h.GetProcessOutput(callerID, args.ProcessID, mode, 0)
tail, err := h.GetProcessOutput(callerID, mcp.ProcessOutputArgs{
ProcessID: args.ProcessID,
Mode: mode,
SinceOffset: tailSince,
MaxBytes: capLimit(args.TailMaxBytes, defaultMCPTailBytes),
})
if err == nil {
res.Tail = &tail
}
@@ -808,8 +875,30 @@ func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadRead(name string) (string, string, error) {
return h.pads.Read(name)
func (h *toolHost) ScratchpadRead(args mcp.ScratchpadReadArgs) (mcp.ScratchpadReadResult, error) {
content, rev, err := h.pads.Read(args.Name)
if err != nil {
return mcp.ScratchpadReadResult{}, err
}
offset := args.Offset
if offset < 0 {
offset = 0
}
if offset > len(content) {
offset = len(content)
}
limited, contentBytes, truncated, truncatedBytes := capTextHead(content[offset:], capLimit(args.MaxBytes, defaultScratchpadReadBytes))
next := offset + contentBytes
return mcp.ScratchpadReadResult{
Content: limited,
Revision: rev,
Offset: offset,
NextOffset: next,
ContentBytes: contentBytes,
TotalBytes: len(content),
Truncated: truncated,
TruncatedBytes: truncatedBytes,
}, nil
}
func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
@@ -828,7 +917,15 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
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{
ProcessID: callerID,
Role: h.CallerRole(callerID),
@@ -836,7 +933,9 @@ func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
Path: h.sess.projectDir,
Key: h.sess.projectKey,
},
AvailableTools: availableToolsForRole(h.CallerRole(callerID)),
}
if includeTools {
w.AvailableTools = availableToolsForRole(h.CallerRole(callerID))
}
if c := h.sess.FindChild(callerID); c != nil {
w.Name = c.DisplayName()
@@ -996,22 +1095,101 @@ func activeScreenName(s pkgvt.Screen) string {
}
}
// ansiRegexp strips CSI escape sequences and common single-character
// controls (BEL, OSC terminators) from the stream. The vt emulator
// already handles full rendering for grid mode; this is only for
// stream-mode ANSI-stripped output.
var ansiRegexp = regexp.MustCompile(`\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
// ansiRegexp strips CSI/OSC escape sequences and common single-character
// controls from the stream. The vt emulator already handles full
// rendering for grid mode; this is only for stream-mode text output.
var ansiRegexp = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x40-\x5f]|\x07`)
func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
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
// string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a
// pattern match (WaitForPattern scrollback). Recognises the same
// shapes the regex did:
// - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR)
// - `\x1b] ... (BEL|ST)` (OSC)
// - `\x1b<final-byte>` for `@..._` (one-byte escapes)
// - `\x07` (BEL)
//
@@ -1041,6 +1219,24 @@ func stripANSIBytes(dst, src []byte) []byte {
continue
}
next := src[i+1]
if next == ']' {
j := i + 2
for j < len(src) {
if src[j] == 0x07 {
i = j + 1
break
}
if src[j] == 0x1b && j+1 < len(src) && src[j+1] == '\\' {
i = j + 2
break
}
j++
}
if j >= len(src) {
i = len(src)
}
continue
}
if next != '[' {
// One-byte ESC sequence (`\x1b<final>` where final is
// `@..._` per the regex; we drop anything that follows).
@@ -1087,7 +1283,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 {
@@ -1123,7 +1319,7 @@ func helpFor(topic string) mcp.HelpResponse {
case "inspection":
return mcp.HelpResponse{
Topic: "inspection",
Content: "get_process_output gives you the visible pane (grid mode) or a byte slice from since_offset (stream mode). list_processes is for the whole session. get_project_status batches everything you need to orient yourself.",
Content: "get_process_output gives you canonical terminal text by default: the visible pane (grid mode) or recent stream text from since_offset (stream mode), with ANSI/control noise, borders, duplicate status churn, and volatile timers removed. Use raw:true only when you need diagnostic PTY bytes; include_meta:true restores cursor, geometry, and screen-version fields. list_processes is for the whole session. get_project_status batches everything you need to orient yourself.",
RelatedTools: []string{"list_processes", "get_process_status", "get_process_output", "search_output", "wait_for_pattern", "get_project_status"},
}
case "io":
@@ -1142,8 +1338,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

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/scratchpad"
)
// mkChild builds a Child without starting a PTY. Use sparingly — the
@@ -134,6 +135,42 @@ func TestWrapSubAgentPromptEmptyStaysEmpty(t *testing.T) {
}
}
func TestMCPContentCapsPreferRecentStreamBytes(t *testing.T) {
got, gotBytes, truncated, dropped := capBytesTail([]byte("abcdefghijklmnop"), 6)
if got != "klmnop" || gotBytes != 6 || !truncated || dropped != 10 {
t.Fatalf("capBytesTail = (%q, %d, %v, %d)", got, gotBytes, truncated, dropped)
}
}
func TestMCPGridCapKeepsHeadAndTail(t *testing.T) {
got, gotBytes, truncated, dropped := capTextMiddle("abcdefghijklmnopqrstuvwxyz", 24)
if gotBytes != 24 || !truncated || dropped != 2 {
t.Fatalf("capTextMiddle metadata = (%d, %v, %d), content %q", gotBytes, truncated, dropped, got)
}
if !strings.Contains(got, "...[truncated]...") {
t.Fatalf("capTextMiddle missing marker: %q", got)
}
}
func TestScratchpadReadPagesLargeContent(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
store, err := scratchpad.Open("test-project")
if err != nil {
t.Fatalf("scratchpad open: %v", err)
}
if _, err := store.Write("notes.md", "abcdefghijklmnopqrstuvwxyz", ""); err != nil {
t.Fatalf("scratchpad write: %v", err)
}
h := &toolHost{pads: store}
res, err := h.ScratchpadRead(mcp.ScratchpadReadArgs{Name: "notes.md", Offset: 5, MaxBytes: 7})
if err != nil {
t.Fatalf("ScratchpadRead: %v", err)
}
if res.Content != "fghijkl" || !res.Truncated || res.NextOffset != 12 || res.TotalBytes != 26 {
t.Fatalf("ScratchpadRead result = %+v", res)
}
}
func TestHelpLifecycleTopicCoversCleanup(t *testing.T) {
resp := helpFor("lifecycle")
if resp.Topic != "lifecycle" {

View File

@@ -118,7 +118,8 @@ func compilePatterns(ps []string) []*regexp.Regexp {
// - titleIdleMS: ms since the last OSC title change (0 if no title yet)
// - title: current OSC title
// - tail: recent output bytes for regex matching
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail []byte) (IdleState, string) {
// - screen: current rendered screen text for persistent prompt matching
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail, screen []byte) (IdleState, string) {
if exited {
if exitNonZero {
return StateError, "process exited non-zero"
@@ -128,14 +129,14 @@ func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titl
if cfg == nil {
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
}
if len(tail) > 0 {
if matchAny(cfg.errorRegexes, tail) {
if len(tail) > 0 || len(screen) > 0 {
if matchAny(cfg.errorRegexes, tail, screen) {
return StateError, "error regex matched"
}
if matchAny(cfg.permissionRegexes, tail) {
if matchAny(cfg.permissionRegexes, tail, screen) {
return StatePermission, "permission regex matched"
}
if matchAny(cfg.thinkingRegexes, tail) {
if matchAny(cfg.thinkingRegexes, tail, screen) {
return StateThinking, "thinking regex matched"
}
}
@@ -172,10 +173,12 @@ func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) {
return StateIdle, "quiet for threshold"
}
func matchAny(res []*regexp.Regexp, tail []byte) bool {
func matchAny(res []*regexp.Regexp, texts ...[]byte) bool {
for _, re := range res {
if re.Match(tail) {
return true
for _, text := range texts {
if len(text) > 0 && re.Match(text) {
return true
}
}
}
return false

View File

@@ -30,7 +30,7 @@ func TestClassifyOutputActivity(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil)
got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil, nil)
if got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
@@ -41,22 +41,37 @@ func TestClassifyOutputActivity(t *testing.T) {
func TestClassifyTitleStability(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000}
// Title change recent → working.
if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil); got != StateWorking {
if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil, nil); got != StateWorking {
t.Fatalf("recent title change: got %q", got)
}
// Title stable past threshold → idle.
if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil); got != StateIdle {
if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil, nil); got != StateIdle {
t.Fatalf("stable title: got %q", got)
}
// No title yet: fall back to output activity.
if got, _ := classify(cfg, false, false, 100, 0, "", nil); got != StateWorking {
if got, _ := classify(cfg, false, false, 100, 0, "", nil, nil); got != StateWorking {
t.Fatalf("no title yet, recent output: got %q", got)
}
if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle {
if got, _ := classify(cfg, false, false, 5000, 0, "", nil, nil); got != StateIdle {
t.Fatalf("no title yet, output idle: got %q", got)
}
}
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,
@@ -67,46 +82,51 @@ func TestClassifyTitleStatus(t *testing.T) {
"error": StateError,
},
}
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking {
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil, nil); got != StateThinking {
t.Fatalf("thinking title: got %q", got)
}
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil); got != StatePermission {
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil, nil); got != StatePermission {
t.Fatalf("permission title: got %q", got)
}
// No match in map → fall back to stability.
if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil); got != StateIdle {
if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil, nil); got != StateIdle {
t.Fatalf("unmatched title, stable: got %q", got)
}
}
func TestClassifyPromoterRegex(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOutputActivity,
idleThresholdMS: 2000,
permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)},
errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)},
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)},
strategy: StrategyOutputActivity,
idleThresholdMS: 2000,
permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)},
errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)},
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)},
}
// Permission promoter beats idle.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); got != StatePermission {
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]"), nil); got != StatePermission {
t.Fatalf("permission promoter: got %q", got)
}
// Error trumps permission.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?")); got != StateError {
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?"), nil); got != StateError {
t.Fatalf("error promoter beats permission: got %q", got)
}
// Thinking promoter on idle output.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…")); got != StateThinking {
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…"), nil); got != StateThinking {
t.Fatalf("thinking promoter: got %q", got)
}
// Rendered-screen prompts still promote even when the raw tail no
// longer contains the original prompt bytes.
if got, _ := classify(cfg, false, false, 100, 0, "", []byte("Calling patterm..."), []byte("Approve? [y/n]")); got != StatePermission {
t.Fatalf("screen permission promoter: got %q", got)
}
}
func TestClassifyExitTerminal(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
if got, _ := classify(cfg, true, true, 0, 0, "", nil); got != StateError {
if got, _ := classify(cfg, true, true, 0, 0, "", nil, nil); got != StateError {
t.Fatalf("non-zero exit: got %q", got)
}
if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle {
if got, _ := classify(cfg, true, false, 0, 0, "", nil, nil); got != StateIdle {
t.Fatalf("clean exit: got %q", got)
}
}

View File

@@ -261,15 +261,11 @@ func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir
}
func (l *Launcher) writeMCPConfig(identity string) (string, error) {
dir, err := preset.ConfigDir()
dir, err := mcpRuntimeDir(identity)
if err != nil {
return "", err
}
dir = filepath.Join(dir, "mcp")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
path := filepath.Join(dir, identity+".json")
path := filepath.Join(dir, "mcp.json")
cfg := map[string]any{
"mcpServers": map[string]any{
"patterm": map[string]any{

View File

@@ -0,0 +1,30 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteMCPConfigUsesRuntimeDir(t *testing.T) {
runtimeDir := t.TempDir()
configHome := filepath.Join(t.TempDir(), "config")
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
t.Setenv("XDG_CONFIG_HOME", configHome)
l := &Launcher{bin: "patterm", mcpSocket: "/tmp/patterm.sock"}
path, err := l.writeMCPConfig("abc123")
if err != nil {
t.Fatalf("writeMCPConfig: %v", err)
}
if !strings.HasPrefix(path, filepath.Join(runtimeDir, "patterm", "agents", "abc123")) {
t.Fatalf("path = %q, want under runtime dir", path)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("config file stat: %v", err)
}
if _, err := os.Stat(filepath.Join(configHome, "patterm")); !os.IsNotExist(err) {
t.Fatalf("writeMCPConfig created XDG config dir or unexpected stat error: %v", err)
}
}

View File

@@ -102,10 +102,9 @@ type renameForm struct {
}
type settingsInputForm struct {
title string
field string
value []rune
subtitle string
title string
field string
value []rune
}
// paletteState is the in-memory model for the overlay. SPEC §4: a
@@ -268,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,
@@ -1277,8 +1285,11 @@ func (p *paletteState) handleSettingsTextInput(chunk []byte, i int) (paletteActi
}
switch b {
case '\r', '\n':
p.applySettingsInput()
changed := p.applySettingsInput()
p.mode = paletteModeAutoSummary
if changed {
return p.settingsAction("settings-save"), false, 1
}
case 0x7f, 0x08:
if len(p.settingsInput.value) > 0 {
p.settingsInput.value = p.settingsInput.value[:len(p.settingsInput.value)-1]
@@ -1308,9 +1319,6 @@ func autoSummaryRows() []autoSummaryRow {
{key: "cadence", label: "Cadence"},
{key: "test", label: "Test summarizer"},
{key: "run_now", label: "Summarize current top-level agent now"},
{key: "save", label: "Save settings"},
{key: "cancel", label: "Cancel"},
{key: "back", label: "Back to Settings"},
}
}
@@ -1322,6 +1330,8 @@ func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) {
switch rows[p.cursor].key {
case "enabled":
p.settings.AutoSummary.Enabled = !p.settings.AutoSummary.Enabled
p.settings.normalize()
return p.settingsAction("settings-save"), false, 1
case "provider":
switch p.settings.AutoSummary.Provider {
case "codex":
@@ -1331,13 +1341,14 @@ func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) {
default:
p.settings.AutoSummary.Provider = "codex"
}
p.settings.normalize()
return p.settingsAction("settings-save"), false, 1
case "codex_model", "opencode_model", "claude_model":
provider := strings.TrimSuffix(rows[p.cursor].key, "_model")
p.settingsInput = &settingsInputForm{
title: provider + " model",
field: rows[p.cursor].key,
value: []rune(p.settings.AutoSummary.modelFor(provider)),
subtitle: "model flag passed to " + provider,
title: provider + " model",
field: rows[p.cursor].key,
value: []rune(p.settings.AutoSummary.modelFor(provider)),
}
p.mode = paletteModeSettingsInput
case "cadence":
@@ -1349,48 +1360,42 @@ func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) {
default:
p.settings.AutoSummary.Cadence = "15s"
}
p.settings.normalize()
return p.settingsAction("settings-save"), false, 1
case "test":
return p.settingsAction("settings-test"), true, 1
case "run_now":
return p.settingsAction("settings-run-now"), true, 1
case "save":
return p.settingsAction("settings-close"), true, 1
case "cancel":
return paletteAction{kind: "cancel"}, true, 1
case "back":
p.mode = paletteModeSettings
p.cursor = 0
p.query = nil
p.rebuildSettings()
}
p.settings.normalize()
return paletteAction{}, false, 1
}
func (p *paletteState) applySettingsInput() {
func (p *paletteState) applySettingsInput() bool {
if p.settingsInput == nil {
return
return false
}
val := strings.TrimSpace(string(p.settingsInput.value))
if val == "" {
return
return false
}
if p.settings.AutoSummary.Models == nil {
p.settings.AutoSummary.Models = defaultSummaryModels()
}
changed := false
switch p.settingsInput.field {
case "codex_model":
changed = p.settings.AutoSummary.Models["codex"] != val
p.settings.AutoSummary.Models["codex"] = val
case "opencode_model":
changed = p.settings.AutoSummary.Models["opencode"] != val
p.settings.AutoSummary.Models["opencode"] = val
case "claude_model":
changed = p.settings.AutoSummary.Models["claude"] != val
p.settings.AutoSummary.Models["claude"] = val
}
p.settings.normalize()
}
func (p *paletteState) settingsCloseAction() paletteAction {
return p.settingsAction("settings-close")
return changed
}
func (p *paletteState) settingsAction(kind string) paletteAction {
@@ -1399,7 +1404,7 @@ func (p *paletteState) settingsAction(kind string) paletteAction {
}
func (p *paletteState) renderSettings(out writeFlusher, cols, rows int) {
p.renderSimplePicker(out, cols, rows, "Settings", "esc cancel", "search settings")
p.renderSimplePicker(out, cols, rows, "Settings", "esc close", "search settings")
}
func (p *paletteState) renderSimplePicker(out writeFlusher, cols, rows int, title, hint, placeholder string) {
@@ -1435,7 +1440,7 @@ func (p *paletteState) renderSimplePicker(out writeFlusher, cols, rows int, titl
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ open · esc cancel · ↑↓ navigate" + styleReset
footer := styleHint + "↵ open · esc close · ↑↓ navigate" + styleReset
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset)
row++
@@ -1451,7 +1456,7 @@ func (p *paletteState) renderAutoSummary(out writeFlusher, cols, rows int) {
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
title := "Auto-summarization"
hint := "esc cancel"
hint := "esc close"
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
@@ -1473,7 +1478,7 @@ func (p *paletteState) renderAutoSummary(out writeFlusher, cols, rows int) {
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ edit/toggle · cadence 15s/30s/1m · save row commits · esc cancel" + styleReset
footer := styleHint + "↵ edit/toggle · esc close" + styleReset
if visibleLen(footer) > content {
footer = clipRunes(footer, content-1) + "…"
}
@@ -1503,7 +1508,7 @@ func (p *paletteState) autoSummaryDisplayRows() []string {
var out []string
for _, row := range autoSummaryRows() {
if v, ok := values[row.key]; ok {
out = append(out, row.label+": "+v)
out = append(out, styleHint+row.label+":"+styleReset+" "+v)
} else {
out = append(out, row.label)
}
@@ -1520,19 +1525,10 @@ func (p *paletteState) renderSettingsInput(out writeFlusher, cols, rows int) {
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
title := p.settingsInput.title
hint := "esc cancel"
hint := "esc back"
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
if p.settingsInput.subtitle != "" {
sub := p.settingsInput.subtitle
if visibleLen(sub) > content {
sub = clipRunes(sub, content-1) + "…"
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + sub + styleReset + strings.Repeat(" ", max(0, content-visibleLen(sub))) + " " + styleBorder + "│" + styleReset)
row++
}
value := string(p.settingsInput.value)
if visibleLen(value) > content-2 {
value = clipRunes(value, content-3) + "…"
@@ -1544,7 +1540,7 @@ func (p *paletteState) renderSettingsInput(out writeFlusher, cols, rows int) {
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ save · esc cancel · ⌃u clear" + styleReset
footer := styleHint + "↵ apply · esc back · ⌃u clear" + styleReset
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset)
row++

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

@@ -1,6 +1,7 @@
package app
import (
"bytes"
"strings"
"testing"
@@ -356,20 +357,95 @@ func TestAutoSummaryCadenceCyclesSoloValues(t *testing.T) {
if p.settings.AutoSummary.Cadence != "1m" {
t.Fatalf("initial cadence = %q", p.settings.AutoSummary.Cadence)
}
p.activateAutoSummaryRow()
action, done, _ := p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("first cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "15s" {
t.Fatalf("first cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
p.activateAutoSummaryRow()
action, done, _ = p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("second cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "30s" {
t.Fatalf("second cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
p.activateAutoSummaryRow()
action, done, _ = p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("third cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "1m" {
t.Fatalf("third cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
}
func TestAutoSummaryScreenOmitsExplicitSaveCancelBackRows(t *testing.T) {
omitted := map[string]bool{
"Save settings": true,
"Cancel": true,
"Back to Settings": true,
}
for _, row := range autoSummaryRows() {
if omitted[row.label] {
t.Fatalf("auto-summary settings should not show %q", row.label)
}
}
}
func TestAutoSummaryRenderOmitsStaleSettingsHelp(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeAutoSummary
var b bytes.Buffer
p.renderAutoSummary(wrapWriter(&b), 100, 30)
out := b.String()
for _, text := range []string{
"Save settings",
"Cancel",
"Back to Settings",
"changes save",
"applies immediately",
} {
if strings.Contains(out, text) {
t.Fatalf("auto-summary render should not contain %q:\n%s", text, out)
}
}
}
func TestAutoSummaryValueRowsStyleLabelAndValueSeparately(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
rows := p.autoSummaryDisplayRows()
for _, row := range rows {
if strings.Contains(row, "Cadence:") {
if !strings.HasPrefix(row, styleHint+"Cadence:"+styleReset+" ") {
t.Fatalf("cadence row styling = %q", row)
}
if strings.Contains(strings.TrimPrefix(row, styleHint+"Cadence:"+styleReset+" "), styleHint) {
t.Fatalf("cadence value should use regular text styling: %q", row)
}
return
}
}
t.Fatal("missing cadence display row")
}
func TestAutoSummaryTextInputSavesWhenSubmitted(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeSettingsInput
p.settingsInput = &settingsInputForm{
title: "codex model",
field: "codex_model",
value: []rune("custom-model"),
}
action, done, _ := p.handleSettingsTextInput([]byte{'\r'}, 0)
if done || action.kind != "settings-save" {
t.Fatalf("submit action = %+v done=%v, want settings-save without close", action, done)
}
if got := p.settings.AutoSummary.modelFor("codex"); got != "custom-model" {
t.Fatalf("codex model = %q", got)
}
}
func TestPaletteFormCtrlRTogglesRelaunchFromCommandField(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm

View File

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

@@ -91,6 +91,12 @@ type ChildEventListener interface {
// updates a child's IdleState. Listeners use this to repaint the
// sidebar badge and to evaluate idle-aware timers.
OnChildStateChanged(childID string, state IdleState)
// OnChildClosed fires when a child is being removed from the
// session (either via close_process, or — for agent/terminal
// kinds — when the PTY exits and the entry will never be
// restarted). It signals that any pending references to childID
// (e.g. timers owned by or watching it) should be dropped.
OnChildClosed(childID string)
}
func NewSession(projectDir, projectKey string) *Session {
@@ -167,6 +173,12 @@ func (s *Session) emitStateChanged(id string, state IdleState) {
}
}
func (s *Session) emitClosed(id string) {
for _, l := range s.listenersSnapshot() {
l.OnChildClosed(id)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
@@ -374,10 +386,29 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
}
}
s.mu.Unlock()
// Notify listeners outside s.mu so they can take their own locks
// without inversion. Timer manager uses this to drop pending
// timers owned by or watching the closed child — otherwise the
// next classifier tick can deliver a stale fire to the parent.
s.emitClosed(id)
s.forgetPersisted(id)
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 {
@@ -486,6 +517,7 @@ func (s *Session) reapChild(c *Child, runID uint64) {
}
}
s.mu.Unlock()
s.emitClosed(c.ID)
}
}

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

@@ -331,7 +331,7 @@ func (st *uiState) drawSidebar() {
write(prefix + openStyle + nameCell + styleReset + suffix)
}
if summary := st.activeSummaryText(width - 4); summary != "" && row+2 <= maxRow {
if summary := st.activeSummaryRaw(); summary != "" && row+2 <= maxRow {
write("")
for _, line := range wrapSidebarSummary(summary, width-4) {
if row > maxRow {
@@ -417,7 +417,13 @@ func wrapSidebarSummary(s string, width int) []string {
out = append(out, cur)
cur = ""
}
out = append(out, clipRunes(word, width-1)+"…")
for visibleLen(word) > width {
out = append(out, clipRunes(word, width))
word = string([]rune(word)[width:])
}
if word != "" {
cur = word
}
continue
}
if cur == "" {

View File

@@ -404,7 +404,7 @@ func runSummarizerCommand(ctx context.Context, cfg autoSummarySettings, projectD
case "claude":
cmd = exec.CommandContext(ctx, "claude", "--print", "--model", model, prompt)
default:
cmd = exec.CommandContext(ctx, "codex", "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--ask-for-approval", "never", "--model", model, "-")
cmd = exec.CommandContext(ctx, "codex", "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--model", model, "-")
cmd.Stdin = strings.NewReader(prompt)
}
cmd.Dir = projectDir

View File

@@ -42,8 +42,48 @@ func TestWrapSidebarSummaryKeepsWordBoundaries(t *testing.T) {
}
}
long := wrapSidebarSummary("supercalifragilistic short", 8)
if len(long) == 0 || !strings.HasSuffix(long[0], "…") {
t.Fatalf("long word should clip with ellipsis: %#v", long)
if len(long) == 0 || strings.Contains(strings.Join(long, ""), "…") {
t.Fatalf("long word should wrap without ellipsis: %#v", long)
}
for _, line := range long {
if visibleLen(line) > 8 {
t.Fatalf("line %q exceeds width", line)
}
}
}
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)
}
}

View File

@@ -59,12 +59,14 @@ func (st *uiState) drawTabBar() {
newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing
type tabRect struct {
startCol int
width int
label string
active bool
childID string
startCol int
width int
label string
glyph string
glyphStyle string
active bool
}
// 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.
@@ -114,9 +116,16 @@ func (st *uiState) drawTabBar() {
if i < extra {
w++
}
active := c.ID == focus
glyph, glyphStyle := tabIdleGlyph(c.IdleState(), active)
label := c.DisplayName()
labelW := utf8.RuneCountInString(label)
maxLabelW := w - 2 // one pad on each side
// Reserve room for the glyph + its trailing space when present
// (1 + 1 runes), on top of the one-cell pad on each side.
maxLabelW := w - 2
if glyph != "" {
maxLabelW -= 2
}
if maxLabelW < 1 {
maxLabelW = 1
}
@@ -129,10 +138,13 @@ func (st *uiState) drawTabBar() {
labelW = utf8.RuneCountInString(label)
}
tabs = append(tabs, tabRect{
startCol: col,
width: w,
label: label,
active: c.ID == focus,
childID: c.ID,
startCol: col,
width: w,
label: label,
glyph: glyph,
glyphStyle: glyphStyle,
active: active,
})
col += w
}
@@ -151,23 +163,37 @@ func (st *uiState) drawTabBar() {
fmt.Fprintf(&b, "\x1b[3;1H\x1b[%dX", width)
for _, t := range tabs {
// Row 1: centre-ish label inside the tab cell.
// Row 1: centre-ish glyph+label inside the tab cell.
labelW := utf8.RuneCountInString(t.label)
leftPad := (t.width - labelW) / 2
visibleW := labelW
if t.glyph != "" {
visibleW += 2 // glyph + separator space
}
leftPad := (t.width - visibleW) / 2
if leftPad < 1 {
leftPad = 1
}
rightPad := t.width - labelW - leftPad
rightPad := t.width - visibleW - leftPad
if rightPad < 0 {
rightPad = 0
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
cellStyle := styleHint
if t.active {
b.WriteString(styleActive)
} else {
b.WriteString(styleHint)
cellStyle = styleActive
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
b.WriteString(cellStyle)
b.WriteString(strings.Repeat(" ", leftPad))
if t.glyph != "" {
// Glyph uses its own colour so error/permission states pop
// regardless of tab focus, matching the sidebar's vocabulary.
b.WriteString(styleReset)
b.WriteString(t.glyphStyle)
b.WriteString(t.glyph)
b.WriteString(styleReset)
b.WriteString(cellStyle)
b.WriteString(" ")
}
b.WriteString(t.label)
b.WriteString(strings.Repeat(" ", rightPad))
b.WriteString(styleReset)
@@ -195,8 +221,11 @@ func (st *uiState) drawTabBar() {
hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset)
}
if summary := st.activeSummaryText(width - 2); summary != "" {
fmt.Fprintf(&b, "\x1b[2;1H %s%s%s", styleDim, summary, styleReset)
for _, tab := range tabs {
summaryWidth := tab.width - 2
if summary := st.summaryTextFor(tab.childID, summaryWidth); summary != "" {
fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset)
}
}
frame := b.String()
@@ -218,3 +247,29 @@ func (st *uiState) drawTabBar() {
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
}
// tabIdleGlyph returns the one-rune state indicator (and its SGR style)
// to render before a tab's label. Mirrors the sidebar's vocabulary so
// users learn the symbols in one place: ✕ error, ? permission, ◐
// thinking, ○ idle, ● working. Returns ("", "") for StateUnknown so the
// first frame after spawn doesn't show a misleading badge.
func tabIdleGlyph(state IdleState, active bool) (string, string) {
base := styleHint
if active {
base = styleAccent
}
switch state {
case StateError:
return "✕", styleError
case StatePermission:
return "?", styleAccent
case StateThinking:
return "◐", base
case StateIdle:
return "○", base
case StateWorking:
return "●", base
default:
return "", ""
}
}

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,11 +308,79 @@ 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)
}
}
// onChildClosed drops pending timer references to childID. Called
// from Session.Close (and the terminal-corpse cleanup in reapChild)
// via the session listener bus — a deliberate signal from the host
// that childID is gone and the parent is not waiting on it anymore.
//
// Semantics:
// - timers owned by childID are cancelled and deleted: their owner
// is gone, so even if defaultFireFn's IsLive guard would no-op
// the delivery, the entry has no business surviving a close.
// - timers watching childID have childID pruned from t.watched
// (and t.idleBaseline). If t.watched becomes empty the timer is
// cancelled and deleted; we deliberately do NOT synthesise a
// fire here. The parent already received any legitimate idle
// transition before close_process — see allWatchedIdleLocked's
// "treat as satisfied" comment, which only applies to a
// concurrent re-evaluation, not to this explicit-removal hook.
//
// The natural-exit path (reapChild → emitExit for agent/command
// kinds) is NOT routed through here: the classifier emits a final
// idle transition on exit, which fires and deletes any watching
// timers exactly once. Cancelling on exit would swallow that
// legitimate fire and leave the parent never notified.
func (m *timerManager) onChildClosed(childID string) {
m.mu.Lock()
changed := false
for id, t := range m.timers {
if t.ownerID == childID {
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
changed = true
continue
}
if !contains(t.watched, childID) {
continue
}
pruned := t.watched[:0]
for _, w := range t.watched {
if w != childID {
pruned = append(pruned, w)
}
}
t.watched = pruned
if t.idleBaseline != nil {
delete(t.idleBaseline, childID)
}
changed = true
if len(t.watched) == 0 {
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
}
}
m.mu.Unlock()
if changed {
m.notifyChanged()
}
}
// allWatchedIdleLocked reports whether every watched child is now
// idle. Called with m.mu held — uses live Child.IdleState() under the
// child's own atomic, not under m.mu.
@@ -315,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 {
@@ -336,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
}
@@ -343,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 {
@@ -370,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
}
@@ -448,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)
}
@@ -467,14 +561,16 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
if t.status != timerStatusPending && t.status != timerStatusPaused {
continue
}
body, bodyTruncated := timerBodyPreview(t.body)
info := mcp.TimerInfo{
ID: t.id,
Label: t.label,
Body: t.body,
Kind: string(t.kind),
Status: t.status,
OwnerID: t.ownerID,
WatchedIDs: append([]string(nil), t.watched...),
ID: t.id,
Label: t.label,
Body: body,
BodyTruncated: bodyTruncated,
Kind: string(t.kind),
Status: t.status,
OwnerID: t.ownerID,
WatchedIDs: append([]string(nil), t.watched...),
}
if t.status == timerStatusPending && !t.firesAt.IsZero() {
info.FiresAtUnixMS = t.firesAt.UnixMilli()
@@ -487,6 +583,14 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
return out
}
func timerBodyPreview(body string) (string, bool) {
const max = 500
if len(body) <= max {
return body, false
}
return body[:max], true
}
// activeForChild returns the nearest pending or paused timer attached
// to child id (either owned by it or watching it). Used by the sidebar
// for the "⏱ 12s" indicator. nil when none.
@@ -528,6 +632,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")
@@ -411,3 +498,178 @@ func TestTimerRecordsRemovedOnIdleFire(t *testing.T) {
t.Fatalf("fired idle timer %s was not removed from registry", resp.ID)
}
}
// TestTimerCloseChildPrunesWatched covers the happy partial-prune
// case: an idle_any timer watches two children, one is closed, the
// timer stays pending and the remaining child can still satisfy it.
func TestTimerCloseChildPrunesWatched(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
working := StateWorking
a.idleState.Store(&working)
b.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_a")
mgr.mu.Lock()
t1, ok := mgr.timers[resp.ID]
if !ok {
mgr.mu.Unlock()
t.Fatalf("timer was removed but still has live watched")
}
watched := append([]string(nil), t1.watched...)
mgr.mu.Unlock()
if len(watched) != 1 || watched[0] != "p_b" {
t.Fatalf("watched after close: %v, want [p_b]", watched)
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("close synthesised a fire: %+v", got)
}
// p_b can still satisfy the timer.
idle := StateIdle
b.idleState.Store(&idle)
mgr.onChildStateChanged("p_b", StateIdle)
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("post-prune fire: %+v", got)
}
}
// TestTimerCloseLastWatchedCancels is the regression for the
// reported stale-fire symptom: the only watched child is closed,
// so the timer must be cancelled — no synthetic fire, and the
// registry entry must be gone so a trailing classifier tick for the
// removed child cannot re-deliver later.
func TestTimerCloseLastWatchedCancels(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "stale body", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_a")
mgr.mu.Lock()
_, stillThere := mgr.timers[resp.ID]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("timer with no remaining watched should be removed")
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("close synthesised a fire: %+v", got)
}
// Simulate the trailing classifier tick for the now-closed child —
// must not fire.
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("trailing state change re-fired: %+v", got)
}
}
// TestTimerCloseChildIdleAllPartialPrune mirrors the idle_any
// partial-prune for idle_all: pruning a watched child shrinks the
// list; the remaining child going idle then satisfies the timer.
func TestTimerCloseChildIdleAllPartialPrune(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
working := StateWorking
a.idleState.Store(&working)
b.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAll: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
mgr.onChildClosed("p_a")
idle := StateIdle
b.idleState.Store(&idle)
mgr.onChildStateChanged("p_b", StateIdle)
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("idle_all after partial prune: %+v", got)
}
}
// TestTimerCloseOwnerCancelsDelay ensures a delay timer is dropped
// when its owner is closed: no delivery, registry empty, the
// underlying time.Timer is stopped.
func TestTimerCloseOwnerCancelsDelay(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "x", "", 0.1)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
mgr.onChildClosed("p_owner")
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("delay timer was not removed when owner closed")
}
time.Sleep(200 * time.Millisecond) // past the original firesAt
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("delay timer fired after owner close: %+v", got)
}
}
// TestTimerCloseWatchedSubAgent is the exact shape of the reported
// stale-fire bug: orchestrator registers a watcher on a sub-agent,
// the sub-agent is closed, and the orchestrator must receive
// nothing (no stale body delivered after close_process).
func TestTimerCloseWatchedSubAgent(t *testing.T) {
sess, mgr, rec := newTestManager(t)
parent := fakeChild("p_owner")
sub := fakeChild("p_sub")
addChild(sess, parent)
addChild(sess, sub)
working := StateWorking
sub.idleState.Store(&working)
if _, err := mgr.TimerFireWhenIdleAny(
"p_owner",
"codex-review-591 finished. Read your own pane …",
"", []string{"p_sub"}, 0,
); err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_sub")
// Trailing classifier emission for the closed sub-agent must
// not deliver anything to the parent.
mgr.onChildStateChanged("p_sub", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("stale fire delivered to parent after sub-agent close: %+v", got)
}
}

View File

@@ -1,7 +1,6 @@
package app
import (
"fmt"
"os"
"strings"
"sync"
@@ -40,6 +39,11 @@ const toastBoxMaxWidth = 50
// any narrower and there's not enough room for borders + content.
const toastBoxMinWidth = 20
// toastContentRows is how many lines of message body each toast box
// reserves. The dismiss hint lives on the host status strip, so the
// box itself is purely the message.
const toastContentRows = 3
// toastStack owns the ordered list of live toasts. Oldest at
// index 0, newest (visually topmost) at the end. The stack's own
// mutex is intentionally separate from uiState.mu so push / dismiss
@@ -115,6 +119,10 @@ func (st *uiState) notifyToast(kind toastKind, text string) {
// canvas). Each of those paths calls renderToasts at the end, so
// the toast layer is always reapplied on top of a freshly-drawn
// pane. Centralised so push / dismiss / clear share one code path.
//
// The status strip also gains/loses the "Ctrl-N · dismiss" hint as
// the stack toggles between empty and non-empty, so we redraw it
// here too rather than waiting for the chrome ticker.
func (st *uiState) refreshToastSurface() {
st.mu.Lock()
focusedPad := st.focusedPad
@@ -134,6 +142,7 @@ func (st *uiState) refreshToastSurface() {
default:
st.renderEmptyState()
}
st.drawStatusLine()
}
// renderToasts draws the toast stack over the top-right of the
@@ -142,33 +151,62 @@ func (st *uiState) refreshToastSurface() {
// freshly-redrawn pane content. Safe to call when the stack is
// empty (no-op).
func (st *uiState) renderToasts() {
bytes := st.toastOverlayBytes()
if len(bytes) == 0 {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(bytes)
}
// toastOverlayBytes builds the toast layer as a single byte buffer
// without writing to stdout. Returns nil when the stack is empty or
// the layout can't accommodate a box. Callers either write it
// directly (renderToasts) or stitch it onto the end of another
// stdout write so claude/codex/opencode redraws that paint over the
// top-right region can't leave the toast half-erased.
func (st *uiState) toastOverlayBytes() []byte {
items := st.toasts.snapshot()
if len(items) == 0 {
return
return nil
}
st.mu.Lock()
palOpen := st.palette != nil
st.mu.Unlock()
if palOpen {
return
return nil
}
layout := st.layoutSnapshot()
paneCols := int(layout.childCols())
paneRows := int(layout.childRows())
if paneCols < toastBoxMinWidth+2 || paneRows < 3 {
return
if paneCols < toastBoxMinWidth+2 || paneRows < toastContentRows+2 {
return nil
}
boxWidth := toastBoxMaxWidth
if max := paneCols - 4; max < boxWidth {
boxWidth = max
}
if boxWidth < toastBoxMinWidth {
return
return nil
}
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
// Reserve two columns for the icon prefix on row 1 so wrapped rows
// indent under the body text rather than under the glyph.
const iconCols = 2
bodyRoom := contentWidth - iconCols
if bodyRoom < 1 {
return nil
}
var b strings.Builder
b.WriteString("\x1b7\x1b[?25l")
// Wrap the whole overlay in DECSET 2026 (synchronized output)
// brackets so terminals that support BSU/ESU buffer the box paint
// into a single frame — without this, claude's continuous redraws
// and our overlay race on each cell, producing visible flicker.
// Terminals that don't recognise 2026 ignore the brackets, so the
// fallback behaviour is the same as before.
b.WriteString("\x1b[?2026h\x1b7\x1b[?25l")
row := int(layout.mainTop) + 1
col := int(layout.mainLeft) + paneCols - boxWidth - 1
@@ -180,20 +218,13 @@ func (st *uiState) renderToasts() {
// reverse so the most recent push lands at the smallest row.
for idx := len(items) - 1; idx >= 0; idx-- {
t := items[idx]
isTopmost := idx == len(items)-1
hintLine := ""
if isTopmost && len(items) > 1 {
hintLine = fmt.Sprintf("Ctrl-N · %d more", len(items)-1)
}
height := 3
if hintLine != "" {
height++
}
height := toastContentRows + 2
// Stop if we'd run off the bottom of the pane.
if row+height > int(layout.mainTop)+paneRows {
break
}
border := toastBorderStyle(t.kind)
wrapped := wrapToastBody(t.text, bodyRoom)
// Top border.
moveTo(&b, row, col)
@@ -204,40 +235,22 @@ func (st *uiState) renderToasts() {
b.WriteString(styleReset)
row++
// Content row.
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
b.WriteString(" ")
b.WriteString(toastIcon(t.kind))
body := t.text
bodyRoom := contentWidth - 2 // icon + space
if visibleLen(body) > bodyRoom {
body = clipRunes(body, bodyRoom-1) + "…"
}
b.WriteString(body)
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(body))))
b.WriteString(" ")
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
row++
// Hint row (topmost only, when stack has more than one).
if hintLine != "" {
if visibleLen(hintLine) > contentWidth {
hintLine = clipRunes(hintLine, contentWidth-1) + "…"
}
// Content rows. Row 0 carries the kind glyph; rows 1..N indent
// by iconCols spaces so wrapped text lines up under the body.
for i := 0; i < toastContentRows; i++ {
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
b.WriteString(" ")
b.WriteString(styleHint)
b.WriteString(hintLine)
b.WriteString(styleReset)
b.WriteString(strings.Repeat(" ", max(0, contentWidth-visibleLen(hintLine))))
if i == 0 {
b.WriteString(toastIcon(t.kind))
} else {
b.WriteString(strings.Repeat(" ", iconCols))
}
line := wrapped[i]
b.WriteString(line)
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(line))))
b.WriteString(" ")
b.WriteString(border)
b.WriteString("│")
@@ -258,11 +271,8 @@ func (st *uiState) renderToasts() {
row++
}
b.WriteString("\x1b[?25h\x1b8")
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.WriteString(b.String())
b.WriteString("\x1b[?25h\x1b8\x1b[?2026l")
return []byte(b.String())
}
func toastBorderStyle(kind toastKind) string {
@@ -276,6 +286,69 @@ func toastBorderStyle(kind toastKind) string {
}
}
// wrapToastBody word-wraps text into exactly toastContentRows lines,
// each at most width visible runes wide. Short messages are padded
// with empty trailing lines so callers can iterate a fixed-size
// slice; messages that don't fit get ellipsized on the last line.
func wrapToastBody(text string, width int) []string {
out := make([]string, toastContentRows)
if width < 1 {
return out
}
all := wrapToastWords(text, width)
if len(all) > toastContentRows {
all = all[:toastContentRows]
last := all[len(all)-1]
if visibleLen(last) >= width {
last = clipRunes(last, width-1) + "…"
} else {
last = last + "…"
}
all[len(all)-1] = last
}
for i, l := range all {
out[i] = l
}
return out
}
// wrapToastWords is a small word-wrapper sized for toast bodies:
// greedy, breaks overlong words on rune boundaries, drops collapsing
// whitespace via strings.Fields.
func wrapToastWords(text string, width int) []string {
var lines []string
var cur string
flush := func() {
if cur != "" {
lines = append(lines, cur)
cur = ""
}
}
for _, word := range strings.Fields(text) {
for visibleLen(word) > width {
flush()
head := clipRunes(word, width)
lines = append(lines, head)
word = word[len(head):]
}
if word == "" {
continue
}
if cur == "" {
cur = word
continue
}
if visibleLen(cur)+1+visibleLen(word) <= width {
cur += " " + word
continue
}
flush()
cur = word
}
flush()
return lines
}
func toastIcon(kind toastKind) string {
switch kind {
case toastError:

View File

@@ -1,6 +1,9 @@
package app
import "testing"
import (
"strings"
"testing"
)
func TestToastStackPushAndOrder(t *testing.T) {
var s toastStack
@@ -98,3 +101,64 @@ func TestToastStackSnapshotIsCopy(t *testing.T) {
t.Fatalf("snapshot is not an independent copy: %#v", again)
}
}
func TestWrapToastBodyFixedHeight(t *testing.T) {
got := wrapToastBody("short", 20)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
if got[0] != "short" {
t.Fatalf("line 0 = %q, want \"short\"", got[0])
}
if got[1] != "" || got[2] != "" {
t.Fatalf("trailing pads not empty: %#v", got)
}
}
func TestWrapToastBodyWrapsOnWordBoundary(t *testing.T) {
got := wrapToastBody("the quick brown fox jumps over", 10)
// Expect greedy fill: "the quick" (9), "brown fox" (9), "jumps over" (10).
want := []string{"the quick", "brown fox", "jumps over"}
for i, w := range want {
if got[i] != w {
t.Fatalf("line %d = %q, want %q (full=%#v)", i, got[i], w, got)
}
}
}
func TestWrapToastBodyEllipsizesOverflow(t *testing.T) {
got := wrapToastBody("alpha beta gamma delta epsilon zeta eta theta", 6)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
last := got[toastContentRows-1]
if !strings.HasSuffix(last, "…") {
t.Fatalf("overflow should ellipsize last line, got %q (full=%#v)", last, got)
}
if visibleLen(last) > 6 {
t.Fatalf("last line %q exceeds width 6", last)
}
}
func TestWrapToastBodyBreaksOverlongWord(t *testing.T) {
got := wrapToastBody("supercalifragilistic", 6)
if got[0] != "superc" {
t.Fatalf("line 0 = %q, want \"superc\"", got[0])
}
if got[1] != "alifra" {
t.Fatalf("line 1 = %q, want \"alifra\"", got[1])
}
// Third line should hold the rest (possibly ellipsized).
if got[2] == "" {
t.Fatalf("line 2 unexpectedly empty: %#v", got)
}
}
func TestWrapToastBodyEmptyInput(t *testing.T) {
got := wrapToastBody("", 20)
for i, l := range got {
if l != "" {
t.Fatalf("line %d = %q, want \"\"", i, l)
}
}
}

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,37 @@
{
"name": "idle_screen_permission_prompt",
"presets": {
"processes": [
{
"name": "screen-permission",
"argv": [
"sh",
"-lc",
"printf '\\033[2J\\033[HCalling patterm...\\n\\nTool use\\n\\nDo you want to proceed?\\n 1. Yes\\n'; i=0; while [ $i -lt 300 ]; do printf '\\033[HCalling patterm... %03d' $i; i=$((i+1)); done; sleep 60"
],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500,
"permission_patterns": ["Do you want to proceed\\?"]
}
}
]
},
"trust": ["screen-permission"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "screen-permission", "name": "screen-permission"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "permission",
"timeout_ms": 4000
}
]
}

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, 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 (
"encoding/json"
"fmt"
"github.com/hjbdev/patterm/internal/scratchpad"
)
// MCP protocol surface. The patterm server originally exposed each
@@ -43,7 +45,7 @@ var serverInfo = map[string]any{
// up as sub-agents and won't be tied into the patterm lifecycle.
//
// Keep this short — clients vary in how much they surface to the LLM.
const serverInstructions = "You are already running INSIDE patterm; the `patterm` MCP server is connected over the same stdio MCP transport you use for any other MCP server. Use the MCP tools you see in tools/list — do NOT (a) try to launch `patterm` or `patterm mcp-stdio` yourself, (b) poke the Unix socket through perl / nc / socat / curl, or (c) shell out to `claude` / `codex` / `opencode` to start a peer. Any of those bypasses caller-identity and the new agent will land as a stray top-level tab instead of a child under you. Start with `whoami` for your role and the full tool list, then `help('topics')` for orientation. `spawn_agent` is the only correct way to start a sub-agent; `spawn_process` is for non-LLM commands; `list_processes` / `get_process_output` inspect them; `send_input` / `send_message` drive them. Whatever you spawn is yours to `close_process` when done. When you `send_message` a sub-agent, its reply comes back into YOUR pane as `[sub-agent:<name>] …`, not into the sub-agent's output — to wait for it, use `timer_fire_when_idle_any([sub_agent])` and then read your own pane; do NOT `wait_for_pattern` on the sub-agent, that will deadlock until timeout."
const serverInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies."
// toolDescriptor is the shape returned by `tools/list`. inputSchema is
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
@@ -76,37 +78,41 @@ func objectSchema(properties map[string]any, required []string) map[string]any {
}
func stringProp(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
_ = desc
return map[string]any{"type": "string"}
}
func numberProp(desc string) map[string]any {
return map[string]any{"type": "number", "description": desc}
_ = desc
return map[string]any{"type": "number"}
}
func integerProp(desc string) map[string]any {
return map[string]any{"type": "integer", "description": desc}
_ = desc
return map[string]any{"type": "integer"}
}
func booleanProp(desc string) map[string]any {
return map[string]any{"type": "boolean", "description": desc}
_ = desc
return map[string]any{"type": "boolean"}
}
func arrayOfStringsProp(desc string) map[string]any {
_ = desc
return map[string]any{
"type": "array",
"description": desc,
"items": map[string]any{"type": "string"},
"type": "array",
"items": map[string]any{"type": "string"},
}
}
// toolCatalog is the full list advertised via tools/list. Descriptions
// are intentionally short — clients are expected to fetch help() for
// detail. Schemas mirror the param structs in tools.go.
func toolCatalog() []toolDescriptor {
return []toolDescriptor{
func toolCatalog(role CallerRole) []toolDescriptor {
tools := []toolDescriptor{
{
Name: "spawn_agent",
Description: "Spawn a sub-agent from an agent preset and optionally seed it with initial instructions. This is the ONLY correct way to start a sub-agent under you — do not shell out to `claude` / `codex` / `opencode` and do not poke patterm's Unix socket via perl / nc / socat. Either bypasses caller identity and the new agent lands as a stray top-level tab instead of your child. Caller owns lifecycle: when the sub-agent's work is done (it reports back via send_message, or you no longer need it), call close_process on its process_id to free the pane and tear down the PTY. See help('spawning') and help('lifecycle').",
Description: "Spawn a sub-agent from an agent preset.",
InputSchema: objectSchema(map[string]any{
"agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."),
"agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."),
@@ -115,14 +121,14 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "spawn_process",
Description: "Spawn a process: a terminal, a process preset, or a freeform argv command. Caller owns lifecycle: when the process is no longer needed, call close_process to remove its entry (live children are SIGKILL'd first). See help('lifecycle').",
Description: "Spawn a terminal, process preset, or argv command.",
InputSchema: objectSchema(map[string]any{
"kind": stringProp("\"terminal\" or \"command\"."),
"preset": stringProp("Process preset name (mutually exclusive with argv)."),
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Argv vector for freeform commands."},
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"name": stringProp("Display name for the pane."),
"working_dir": stringProp("Working directory for the spawned process."),
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "Extra environment variables."},
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
"shell": booleanProp("Run argv through sh -lc."),
}, nil),
},
@@ -188,23 +194,30 @@ func toolCatalog() []toolDescriptor {
{
Name: "get_project_status",
Description: "One-shot orientation: project, caller, processes, scratchpads.",
InputSchema: objectSchema(nil, nil),
InputSchema: objectSchema(map[string]any{
"include_tools": booleanProp("Include available_tools in caller metadata."),
}, nil),
},
{
Name: "get_process_output",
Description: "Read rendered grid (\"grid\") or ANSI-stripped stream (\"stream\") output, with screen-version watermark.",
Description: "Read canonical terminal text by default: visible grid (\"grid\") or recent stream (\"stream\") with ANSI/control noise, borders, duplicate status churn, and volatile timers removed. Set raw=true only for diagnostic ANSI-preserved PTY bytes.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"mode": stringProp("\"grid\" (default) or \"stream\"."),
"since_offset": integerProp("Watermark offset from a previous call."),
"max_bytes": integerProp("Maximum content bytes to return."),
"max_lines": integerProp("Maximum canonical lines to return (default 120, max 500)."),
"raw": booleanProp("Return raw ANSI-preserved stream bytes instead of canonical text."),
"include_meta": booleanProp("Include verbose cursor, geometry, active screen, idle, and screen-version metadata."),
}, []string{"process_id"}),
},
{
Name: "get_process_raw_output",
Description: "Read the raw ANSI byte stream since since_offset.",
Description: "Compatibility alias for raw=true get_process_output: read the raw ANSI byte stream since since_offset.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"since_offset": integerProp("Byte offset from a previous call."),
"max_bytes": integerProp("Maximum content bytes to return."),
}, []string{"process_id"}),
},
{
@@ -214,12 +227,13 @@ func toolCatalog() []toolDescriptor {
"process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."),
"kind": stringProp("\"rendered\" (default) or \"raw\"."),
"limit": integerProp("Max matches (default 20)."),
"limit": integerProp("Max matches (default 10)."),
"max_bytes": integerProp("Max bytes per returned match line."),
}, []string{"process_id", "pattern"}),
},
{
Name: "wait_for_pattern",
Description: "Block until pattern appears in the TARGET process's own output, or timeout elapses. Use this for waiting on text the target itself will emit (a shell prompt, a build's \"tests passed\" line, etc.). Anti-pattern: do NOT use this to wait for a sub-agent's reply to send_message — replies are routed into the CALLER's pane tagged `[sub-agent:<name>]`, not into the sub-agent's output, so this call will spin to timeout. For sub-agent coordination use `timer_fire_when_idle_any` and then read your own pane.",
Description: "Block until pattern appears in the target process output.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."),
@@ -238,18 +252,19 @@ func toolCatalog() []toolDescriptor {
Name: "send_input",
Description: "Type text, paste a block, or fire a named key into a process. Optional tail-after-send.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
"text": stringProp("Text payload for kind=text/paste."),
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
"submit": booleanProp("Whether to append a submit keystroke."),
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
"process_id": stringProp("Target process id."),
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
"text": stringProp("Text payload for kind=text/paste."),
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
"submit": booleanProp("Whether to append a submit keystroke."),
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
"tail_max_bytes": integerProp("Maximum bytes in returned tail."),
}, []string{"process_id", "kind"}),
},
{
Name: "send_message",
Description: "Deliver a text message to another process as orchestrator-owned input. Fire-and-forget: returns immediately, without waiting for the recipient to read or act. If the recipient replies via send_message, that reply arrives in YOUR pane tagged `[sub-agent:<name>]` (child→parent) or `[orchestrator]` (parent→child) — NOT in the recipient's output. To wait for a sub-agent's reply, schedule `timer_fire_when_idle_any([sub_agent_id], body=…)` and then read your own pane when the timer fires. Do not `wait_for_pattern` on the recipient for a reply; it will deadlock.",
Description: "Send a tagged message to a parent or child process.",
InputSchema: objectSchema(map[string]any{
"target_process_id": stringProp("Recipient process id."),
"message": stringProp("Message body."),
@@ -283,7 +298,7 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "timer_fire_when_idle_any",
Description: "Canonical way to wait for a sub-agent to finish working: send_message the sub-agent, then schedule this with watched=[sub_agent_id]; when it fires, the reply is already sitting in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
Description: "Fire when any watched process becomes idle.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
@@ -294,7 +309,7 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "timer_fire_when_idle_all",
Description: "Canonical way to wait for several sub-agents to finish working in parallel: send_message each one, then schedule this with watched=[…ids]; when it fires, each reply is in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
Description: "Fire when all watched processes are idle.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
@@ -338,7 +353,9 @@ func toolCatalog() []toolDescriptor {
Name: "scratchpad_read",
Description: "Read a scratchpad entry, returning content and revision.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
"name": stringProp("Scratchpad name."),
"offset": integerProp("Byte offset to start reading."),
"max_bytes": integerProp("Maximum content bytes to return."),
}, []string{"name"}),
},
{
@@ -358,10 +375,19 @@ 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.",
InputSchema: objectSchema(nil, nil),
Description: "Return caller identity, role, parent, and project metadata.",
InputSchema: objectSchema(map[string]any{
"include_tools": booleanProp("Include full available tool list."),
}, nil),
},
{
Name: "help",
@@ -371,6 +397,16 @@ func toolCatalog() []toolDescriptor {
}, nil),
},
}
if role != RoleSubAgent {
return tools
}
filtered := tools[:0]
for _, tool := range tools {
if tool.Name != "spawn_agent" {
filtered = append(filtered, tool)
}
}
return filtered
}
// handleProtocolMethod handles MCP protocol-level methods. Returns
@@ -409,7 +445,14 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
return map[string]any{}, true, 0, "", nil
case "tools/list":
return map[string]any{"tools": toolCatalog()}, true, 0, "", nil
role := RoleOrchestrator
s.mu.Lock()
host := s.host
s.mu.Unlock()
if host != nil {
role = host.CallerRole(callerID)
}
return map[string]any{"tools": toolCatalog(role)}, true, 0, "", nil
case "tools/call":
var p struct {
@@ -465,25 +508,12 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
return nil, false, 0, "", nil
}
// wrapToolResult turns a structured tool result into an MCP tools/call
// response. Plain strings (e.g. "ok") become text content; structured
// values are JSON-encoded into a single text block and also exposed
// under structuredContent so capable clients can read the shape.
// wrapToolResult turns a tool result into an MCP tools/call response.
// Structured values are exposed once under structuredContent; content
// carries only a short model-readable summary to avoid duplicating
// large JSON payloads into the transcript.
func wrapToolResult(result any) map[string]any {
var text string
switch v := result.(type) {
case nil:
text = "ok"
case string:
text = v
default:
b, err := json.Marshal(v)
if err != nil {
text = fmt.Sprintf("%v", v)
} else {
text = string(b)
}
}
text := summarizeToolResult(result)
out := map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
"isError": false,
@@ -498,3 +528,70 @@ func wrapToolResult(result any) map[string]any {
}
return out
}
func summarizeToolResult(result any) string {
switch v := result.(type) {
case nil:
return "ok"
case string:
return v
case ProcessInfo:
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
case []ProcessInfo:
return fmt.Sprintf("%d processes", len(v))
case ProcessStatus:
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
case ProjectStatus:
return fmt.Sprintf("%d processes, %d scratchpads", len(v.Processes), len(v.Scratchpads))
case ProcessOutput:
return outputSummary(v.Mode, v.ContentBytes, v.Truncated, v.NewOffset)
case RawOutput:
return outputSummary("raw", v.ContentBytes, v.Truncated, v.NewOffset)
case SearchResult:
if v.Truncated {
return fmt.Sprintf("%d matches (truncated)", len(v.Matches))
}
return fmt.Sprintf("%d matches", len(v.Matches))
case SendInputResult:
if v.Tail != nil {
return "ok; tail included"
}
return "ok"
case TimerHandle:
return "timer " + v.ID
case TimerFireWhenIdleResponse:
if v.ID != "" {
return fmt.Sprintf("%s timer %s", v.Status, v.ID)
}
return v.Status
case []TimerInfo:
return fmt.Sprintf("%d timers", len(v))
case []scratchpad.Entry:
return fmt.Sprintf("%d scratchpads", len(v))
case ScratchpadReadResult:
if v.Truncated {
return fmt.Sprintf("%d/%d bytes from offset %d", v.ContentBytes, v.TotalBytes, v.Offset)
}
return fmt.Sprintf("%d bytes", v.ContentBytes)
case WhoAmI:
if v.ProcessID == "" {
return string(v.Role)
}
return fmt.Sprintf("%s %s", v.ProcessID, v.Role)
case HelpResponse:
return fmt.Sprintf("help: %s", v.Topic)
default:
return "ok"
}
}
func outputSummary(mode string, bytes int, truncated bool, offset int64) string {
s := fmt.Sprintf("%s output: %d bytes", mode, bytes)
if offset > 0 {
s += fmt.Sprintf(", offset %d", offset)
}
if truncated {
s += " (truncated)"
}
return s
}

View File

@@ -2,6 +2,7 @@ package mcp
import (
"encoding/json"
"strings"
"testing"
)
@@ -43,6 +44,9 @@ func TestInitializeReturnsCapabilities(t *testing.T) {
if !ok || instructions == "" {
t.Fatalf("instructions missing or wrong type: %+v", parsed.Result)
}
if len(instructions) > 320 {
t.Fatalf("instructions too verbose: %d chars", len(instructions))
}
}
func TestInitializedNotificationSuppressesResponse(t *testing.T) {
@@ -74,6 +78,9 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
if parsed.Error != nil {
t.Fatalf("tools/list returned error: %+v", parsed.Error)
}
if len(resp) > 12000 {
t.Fatalf("tools/list response too large: %d bytes", len(resp))
}
tools, ok := parsed.Result["tools"].([]interface{})
if !ok {
t.Fatalf("tools not array: %+v", parsed.Result)
@@ -112,6 +119,27 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
}
}
func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) {
result := ProcessOutput{
Content: strings.Repeat("x", 1024),
Mode: "stream",
NewOffset: 2048,
ContentBytes: 1024,
}
wrapped := wrapToolResult(result)
if wrapped["structuredContent"] == nil {
t.Fatalf("structuredContent missing: %#v", wrapped)
}
content := wrapped["content"].([]map[string]any)
text := content[0]["text"].(string)
if strings.Contains(text, result.Content) {
t.Fatalf("content duplicated structured payload: %q", text)
}
if !strings.Contains(text, "stream output") {
t.Fatalf("summary text should identify output, got %q", text)
}
}
func TestPingReturnsEmptyObject(t *testing.T) {
s := &Server{}
req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`)

View File

@@ -74,10 +74,10 @@ type ToolHost interface {
// Inspection.
ListProcesses(callerID, kindFilter string) []ProcessInfo
GetProcessStatus(callerID, processID string) (ProcessStatus, error)
GetProjectStatus(callerID string) (ProjectStatus, error)
GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (ProcessOutput, error)
GetProcessRawOutput(callerID, processID string, sinceOffset int64) (RawOutput, error)
SearchOutput(callerID, processID, pattern, kind string, limit int) (SearchResult, error)
GetProjectStatus(callerID string, includeTools bool) (ProjectStatus, error)
GetProcessOutput(callerID string, args ProcessOutputArgs) (ProcessOutput, error)
GetProcessRawOutput(callerID string, args RawOutputArgs) (RawOutput, error)
SearchOutput(callerID string, args SearchOutputArgs) (SearchResult, error)
WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error)
GetProcessPorts(callerID, processID string) ([]PortSighting, error)
@@ -98,12 +98,13 @@ type ToolHost interface {
// Scratchpads.
ScratchpadList() ([]scratchpad.Entry, error)
ScratchpadRead(name string) (content string, revision string, err error)
ScratchpadRead(args ScratchpadReadArgs) (ScratchpadReadResult, error)
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error
ScratchpadDelete(name string) error
// Meta.
WhoAmI(callerID string) WhoAmI
WhoAmI(callerID string, includeTools bool) WhoAmI
Help(callerID, topic string) HelpResponse
}
@@ -156,32 +157,60 @@ type ProjectStatus struct {
Scratchpads []scratchpad.Entry `json:"scratchpads"`
}
type ProjectStatusArgs struct {
IncludeTools bool `json:"include_tools"`
}
// ProjectMeta is the project root info echoed in many payloads.
type ProjectMeta struct {
Path string `json:"path"`
Key string `json:"key"`
}
// ProcessOutput is the get_process_output payload. SPEC §7 enriches
// the old read_output result with screen geometry + version.
// ProcessOutput is the get_process_output payload. By default it is
// canonical text with light metadata; include_meta restores screen
// geometry + version, and raw requests return stream bytes.
type ProcessOutput struct {
Content string `json:"content"`
Mode string `json:"mode"`
NewOffset int64 `json:"new_offset,omitempty"`
ActiveScreen string `json:"active_screen,omitempty"`
Rows int `json:"rows,omitempty"`
Cols int `json:"cols,omitempty"`
Cursor Cursor `json:"cursor"`
IdleMS int64 `json:"idle_ms,omitempty"`
Status string `json:"status,omitempty"`
ScreenVersion int64 `json:"screen_version,omitempty"`
Content string `json:"content"`
Mode string `json:"mode"`
NewOffset int64 `json:"new_offset,omitempty"`
ActiveScreen string `json:"active_screen,omitempty"`
Rows int `json:"rows,omitempty"`
Cols int `json:"cols,omitempty"`
Cursor *Cursor `json:"cursor,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"`
Status string `json:"status,omitempty"`
ScreenVersion int64 `json:"screen_version,omitempty"`
ContentBytes int `json:"content_bytes,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TruncatedBytes int `json:"truncated_bytes,omitempty"`
Canonicalized bool `json:"canonicalized,omitempty"`
}
type ProcessOutputArgs struct {
ProcessID string `json:"process_id"`
Mode string `json:"mode"`
SinceOffset int64 `json:"since_offset"`
MaxBytes int `json:"max_bytes"`
MaxLines int `json:"max_lines"`
Raw bool `json:"raw"`
IncludeMeta bool `json:"include_meta"`
}
// RawOutput is the get_process_raw_output payload — ANSI preserved.
type RawOutput struct {
Content string `json:"content"`
NewOffset int64 `json:"new_offset"`
Status string `json:"status,omitempty"`
Content string `json:"content"`
NewOffset int64 `json:"new_offset"`
Status string `json:"status,omitempty"`
ContentBytes int `json:"content_bytes,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TruncatedBytes int `json:"truncated_bytes,omitempty"`
}
type RawOutputArgs struct {
ProcessID string `json:"process_id"`
SinceOffset int64 `json:"since_offset"`
MaxBytes int `json:"max_bytes"`
}
// SearchResult is search_output's payload.
@@ -190,6 +219,14 @@ type SearchResult struct {
Truncated bool `json:"truncated"`
}
type SearchOutputArgs struct {
ProcessID string `json:"process_id"`
Pattern string `json:"pattern"`
Kind string `json:"kind"`
Limit int `json:"limit"`
MaxBytes int `json:"max_bytes"`
}
type SearchMatch struct {
LineNo int `json:"line_no"`
Text string `json:"text"`
@@ -244,8 +281,9 @@ 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"
BodyTruncated bool `json:"body_truncated,omitempty"`
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused"
OwnerID string `json:"owner_process_id"`
WatchedIDs []string `json:"watched,omitempty"`
FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"`
@@ -280,13 +318,14 @@ type SpawnProcessArgs struct {
// SendInputArgs is the input shape for send_input — covers text /
// paste / key with the optional wait+tail tail-after-send.
type SendInputArgs struct {
ProcessID string `json:"process_id"`
Kind string `json:"kind"` // "text" | "paste" | "key"
Text string `json:"text"`
Key string `json:"key"`
Submit *bool `json:"submit"`
WaitMS int `json:"wait_ms"`
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
ProcessID string `json:"process_id"`
Kind string `json:"kind"` // "text" | "paste" | "key"
Text string `json:"text"`
Key string `json:"key"`
Submit *bool `json:"submit"`
WaitMS int `json:"wait_ms"`
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
TailMaxBytes int `json:"tail_max_bytes"`
}
// SendInputResult is the return shape of send_input.
@@ -305,6 +344,27 @@ type WhoAmI struct {
AvailableTools []string `json:"available_tools"`
}
type WhoAmIArgs struct {
IncludeTools bool `json:"include_tools"`
}
type ScratchpadReadArgs struct {
Name string `json:"name"`
Offset int `json:"offset"`
MaxBytes int `json:"max_bytes"`
}
type ScratchpadReadResult struct {
Content string `json:"content"`
Revision string `json:"revision"`
Offset int `json:"offset,omitempty"`
NextOffset int `json:"next_offset,omitempty"`
ContentBytes int `json:"content_bytes,omitempty"`
TotalBytes int `json:"total_bytes,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TruncatedBytes int `json:"truncated_bytes,omitempty"`
}
// HelpResponse is the help return shape.
type HelpResponse struct {
Topic string `json:"topic"`
@@ -506,61 +566,51 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
return st, 0, "", nil
case "get_project_status":
ps, err := h.GetProjectStatus(callerID)
var p ProjectStatusArgs
_ = unmarshalParamsOptional(params, &p)
ps, err := h.GetProjectStatus(callerID, p.IncludeTools)
if err != nil {
return mapToolError(err)
}
return ps, 0, "", nil
case "get_process_output":
var p struct {
ProcessID string `json:"process_id"`
Mode string `json:"mode"`
SinceOffset int64 `json:"since_offset"`
}
var p ProcessOutputArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if p.Mode == "" {
p.Mode = "grid"
}
out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset)
out, err := h.GetProcessOutput(callerID, p)
if err != nil {
return mapToolError(err)
}
return out, 0, "", nil
case "get_process_raw_output":
var p struct {
ProcessID string `json:"process_id"`
SinceOffset int64 `json:"since_offset"`
}
var p RawOutputArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
out, err := h.GetProcessRawOutput(callerID, p.ProcessID, p.SinceOffset)
out, err := h.GetProcessRawOutput(callerID, p)
if err != nil {
return mapToolError(err)
}
return out, 0, "", nil
case "search_output":
var p struct {
ProcessID string `json:"process_id"`
Pattern string `json:"pattern"`
Kind string `json:"kind"`
Limit int `json:"limit"`
}
var p SearchOutputArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if p.Limit <= 0 {
p.Limit = 20
p.Limit = 10
}
if p.Kind == "" {
p.Kind = "rendered"
}
res, err := h.SearchOutput(callerID, p.ProcessID, p.Pattern, p.Kind, p.Limit)
res, err := h.SearchOutput(callerID, p)
if err != nil {
return mapToolError(err)
}
@@ -730,17 +780,15 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
return entries, 0, "", nil
case "scratchpad_read":
var p struct {
Name string `json:"name"`
}
var p ScratchpadReadArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
content, rev, err := h.ScratchpadRead(p.Name)
res, err := h.ScratchpadRead(p)
if err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"content": content, "revision": rev}, 0, "", nil
return res, 0, "", nil
case "scratchpad_write":
var p struct {
@@ -776,8 +824,22 @@ 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
var p WhoAmIArgs
_ = unmarshalParamsOptional(params, &p)
return h.WhoAmI(callerID, p.IncludeTools), 0, "", nil
case "help":
var p struct {

View File

@@ -4,6 +4,7 @@
package preset
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -35,15 +36,16 @@ type Preset struct {
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Disabled bool `json:"disabled,omitempty"`
// Process-only.
Shell bool `json:"shell,omitempty"`
// Agent-only. SPEC §10.
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
IdleDetection *IdleDetection `json:"idle_detection,omitempty"`
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
IdleDetection *IdleDetection `json:"idle_detection,omitempty"`
}
// IdleDetection configures steady-state idle classification for an
@@ -119,28 +121,22 @@ type Set struct {
Processes []*Preset
}
// Load scans the standard locations under $XDG_CONFIG_HOME/patterm/
// presets/{agents,processes}/*.json. Unknown files are skipped with a
// warning to stderr; the spec is forgiving here.
// Load returns the built-in presets plus user overlays from
// $XDG_CONFIG_HOME/patterm/presets/{agents,processes}/*.json. Startup
// does not write default files; user files only override or extend the
// in-memory defaults. A user overlay with {"disabled": true} hides a
// built-in preset of the same name.
func Load() (Set, error) {
base, err := ConfigDir()
if err != nil {
return Set{}, err
}
if err := os.MkdirAll(base, 0o700); err != nil {
return Set{}, fmt.Errorf("preset: mkdir %s: %w", base, err)
}
// Make sure the default-preset files exist on first run. Idempotent.
if err := ensureDefaults(base); err != nil {
return Set{}, err
}
agents, err := loadDir(filepath.Join(base, "presets", "agents"), KindAgent)
agents, err := loadWithDefaults(filepath.Join(base, "presets", "agents"), KindAgent, defaultAgentPresets())
if err != nil {
return Set{}, err
}
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindCommand)
procs, err := loadWithDefaults(filepath.Join(base, "presets", "processes"), KindCommand, nil)
if err != nil {
return Set{}, err
}
@@ -160,51 +156,154 @@ func ConfigDir() (string, error) {
return filepath.Join(home, ".config", "patterm"), nil
}
func loadDir(dir string, kind Kind) ([]*Preset, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("preset: mkdir %s: %w", dir, err)
func loadWithDefaults(dir string, kind Kind, defaults []*Preset) ([]*Preset, error) {
byName := make(map[string]*Preset, len(defaults))
for _, p := range defaults {
cp := clonePreset(p)
cp.Kind = kind
byName[cp.Name] = cp
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return sortedPresets(byName), nil
}
return nil, fmt.Errorf("preset: read %s: %w", dir, err)
}
var out []*Preset
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(dir, e.Name())
p, err := loadFile(path, kind)
p, err := loadFileOverlay(path, kind, byName)
if err != nil {
fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err)
continue
}
if p.Disabled {
delete(byName, p.Name)
continue
}
byName[p.Name] = p
}
return sortedPresets(byName), nil
}
func sortedPresets(byName map[string]*Preset) []*Preset {
out := make([]*Preset, 0, len(byName))
for _, p := range byName {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
return out
}
func loadFile(path string, kind Kind) (*Preset, error) {
func loadFileOverlay(path string, kind Kind, defaults map[string]*Preset) (*Preset, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var header struct {
Name string `json:"name"`
Disabled bool `json:"disabled,omitempty"`
}
if err := json.Unmarshal(b, &header); err != nil {
return nil, err
}
if header.Name == "" {
return nil, errors.New("missing 'name'")
}
if def := defaults[header.Name]; def != nil {
p, err := mergePreset(def, b)
if err != nil {
return nil, err
}
p.Path = path
p.Kind = kind
return p, validatePreset(p)
}
var p Preset
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
p.Path = path
p.Kind = kind
return &p, validatePreset(&p)
}
func validatePreset(p *Preset) error {
if p.Name == "" {
return errors.New("missing 'name'")
}
if p.Disabled {
return nil
}
if len(p.Argv) == 0 && !p.Shell {
return errors.New("missing 'argv'")
}
return nil
}
func mergePreset(def *Preset, overlay []byte) (*Preset, error) {
base, err := presetMap(def)
if err != nil {
return nil, err
}
var over map[string]any
dec := json.NewDecoder(bytes.NewReader(overlay))
dec.UseNumber()
if err := dec.Decode(&over); err != nil {
return nil, err
}
deepMerge(base, over)
b, err := json.Marshal(base)
if err != nil {
return nil, err
}
var p Preset
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
if p.Name == "" {
return nil, errors.New("missing 'name'")
}
if len(p.Argv) == 0 && !p.Shell {
return nil, errors.New("missing 'argv'")
}
p.Path = path
p.Kind = kind
return &p, nil
}
func presetMap(p *Preset) (map[string]any, error) {
b, err := json.Marshal(p)
if err != nil {
return nil, err
}
var m map[string]any
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
if err := dec.Decode(&m); err != nil {
return nil, err
}
return m, nil
}
func deepMerge(dst, src map[string]any) {
for k, v := range src {
if sm, ok := v.(map[string]any); ok {
if dm, ok := dst[k].(map[string]any); ok {
deepMerge(dm, sm)
continue
}
}
dst[k] = v
}
}
func clonePreset(p *Preset) *Preset {
if p == nil {
return nil
}
b, _ := json.Marshal(p)
var out Preset
_ = json.Unmarshal(b, &out)
return &out
}
// ResolvedArgv returns the argv to actually exec, handling the
// process-preset "shell: true" case (SPEC §10).
func (p *Preset) ResolvedArgv() []string {
@@ -214,17 +313,9 @@ func (p *Preset) ResolvedArgv() []string {
return p.Argv
}
// ensureDefaults writes default agent presets (claude/codex/opencode)
// and a sample process preset on first run. Never overwrites existing
// user files.
func ensureDefaults(base string) error {
defaults := []struct {
rel string
body string
}{
{
"presets/agents/claude.json",
`{
func defaultAgentPresets() []*Preset {
bodies := []string{
`{
"name": "claude",
"argv": ["claude"],
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
@@ -249,10 +340,7 @@ func ensureDefaults(base string) error {
]
}
`,
},
{
"presets/agents/codex.json",
`{
`{
"name": "codex",
"argv": ["codex"],
"mcp_injection": {
@@ -264,7 +352,10 @@ func ensureDefaults(base string) error {
"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",
@@ -275,10 +366,7 @@ func ensureDefaults(base string) error {
]
}
`,
},
{
"presets/agents/opencode.json",
`{
`{
"name": "opencode",
"argv": ["opencode"],
"mcp_injection": {
@@ -301,19 +389,15 @@ func ensureDefaults(base string) error {
]
}
`,
},
}
for _, d := range defaults {
full := filepath.Join(base, d.rel)
if _, err := os.Stat(full); err == nil {
continue
}
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
return err
}
if err := os.WriteFile(full, []byte(d.body), 0o600); err != nil {
return err
out := make([]*Preset, 0, len(bodies))
for _, body := range bodies {
var p Preset
if err := json.Unmarshal([]byte(body), &p); err != nil {
panic(err)
}
p.Kind = KindAgent
out = append(out, &p)
}
return nil
return out
}

View File

@@ -0,0 +1,131 @@
package preset
import (
"os"
"path/filepath"
"testing"
)
func TestLoadUsesBuiltInDefaultsWithoutWritingConfig(t *testing.T) {
configHome := filepath.Join(t.TempDir(), "config")
t.Setenv("XDG_CONFIG_HOME", configHome)
set, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if _, err := os.Stat(filepath.Join(configHome, "patterm")); !os.IsNotExist(err) {
t.Fatalf("Load created config dir or unexpected stat error: %v", err)
}
if len(set.Agents) != 3 {
t.Fatalf("agents len = %d, want 3", len(set.Agents))
}
claude := presetByName(set.Agents, "claude")
if claude == nil {
t.Fatal("missing built-in claude preset")
}
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) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
dir := filepath.Join(configHome, "patterm", "presets", "agents")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "claude.json"), `{
"name": "claude",
"argv": ["claude", "--model", "sonnet"],
"idle_detection": { "idle_threshold_ms": 3500 }
}`)
set, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
claude := presetByName(set.Agents, "claude")
if claude == nil {
t.Fatal("missing claude preset")
}
if got := claude.Argv; len(got) != 3 || got[0] != "claude" || got[2] != "sonnet" {
t.Fatalf("argv = %#v", got)
}
if claude.IdleDetection.IdleThresholdMS != 3500 {
t.Fatalf("idle threshold = %d", claude.IdleDetection.IdleThresholdMS)
}
if len(claude.IdleDetection.PermissionPatterns) == 0 {
t.Fatalf("permission patterns were not inherited: %+v", claude.IdleDetection)
}
if claude.MCPInjection == nil || claude.MCPInjection.Kind != "flag" {
t.Fatalf("mcp injection was not inherited: %+v", claude.MCPInjection)
}
}
func TestLoadCanDisableBuiltInPreset(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
dir := filepath.Join(configHome, "patterm", "presets", "agents")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "opencode.json"), `{"name":"opencode","disabled":true}`)
set, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if presetByName(set.Agents, "opencode") != nil {
t.Fatal("opencode preset was not disabled")
}
if presetByName(set.Agents, "claude") == nil || presetByName(set.Agents, "codex") == nil {
t.Fatalf("other built-ins missing: %+v", set.Agents)
}
}
func TestLoadAddsCustomUserPreset(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
dir := filepath.Join(configHome, "patterm", "presets", "processes")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "test.json"), `{"name":"test","argv":["go","test","./..."]}`)
set, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
proc := presetByName(set.Processes, "test")
if proc == nil {
t.Fatal("missing custom process preset")
}
if proc.Kind != KindCommand {
t.Fatalf("kind = %q", proc.Kind)
}
}
func presetByName(ps []*Preset, name string) *Preset {
for _, p := range ps {
if p.Name == name {
return p
}
}
return nil
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}