11 Commits

Author SHA1 Message Date
4b4e7543e8 Release v0.0.2
Some checks failed
release / build-linux-amd64 (push) Failing after 10m12s
Bundles the in-flight work into the second tagged release. See
CHANGELOG.md `[0.0.2] - 2026-05-15` for the full per-change list.
Highlights:

- libghostty-vt was building in zig's silent Debug default, capping
  the full pipeline at 34-63 fps. Makefile now defaults to
  ReleaseFast (.mise.toml pins zig 0.15.2 so the build is
  reproducible). End-to-end pipeline now runs at 930-2030 fps —
  27-32× faster, with 7-16× headroom over a 120 fps target.
- --debug[=DIR] and --profile[=DIR] flags capture full PTY logs,
  pprof data, and per-hot-path metrics (chunks/sec, mean/max
  latencies, cache hit rates) for offline analysis. Nothing
  pollutes stdout/stderr.
- ASCII-video benchmark suite (8-colour / truecolor / Bad-Apple
  patterns at 30/60/120 fps) plus a renderer microbenchmark set
  for stable A/B comparisons across changes.
- Click-and-drag text selection from alt-screen TUIs (codex) now
  works — host mouse mode follows the focused child's screen side
  instead of being permanently armed.
- Long claude session resume + codex steady-state rendering pay
  less per chunk: drawSidebar deferred to the chrome ticker,
  emulator.Title CGO poll gated on a containsOSC scan.
- Vendor-TUI orientation: MCP initialize.instructions, the
  spawn_agent tool description, and help('spawning') all spell
  out the anti-patterns (shell-out, perl-into-socket) that
  produced codex's stray top-level tabs.
2026-05-15 14:22:59 +01:00
bda799a3c6 mise-pin zig 0.15.2; rebuild libghostty-vt ReleaseFast — 27-32x pipeline speedup
Added .mise.toml pinning zig = "0.15.2" (the minimum the vendored
Ghostty commit requires) and taught the Makefile to resolve zig
through mise when available, falling back to PATH. Contributors run
`mise install` once and `make deps` just works.

Re-ran the pipeline benchmarks after rebuilding libghostty-vt with
ReleaseFast (same hardware, AMD Ryzen 7 7800X3D):

                                Debug         ReleaseFast    speedup
  Pipeline 8-colour @120fps     63 fps         2030 fps       32x
  Pipeline truecolor @120fps    34 fps          931 fps       27x
  Emulator-only truecolor       34 fps         2051 fps       60x

7-16x headroom over 120 fps for the heaviest workload (truecolor
full-screen redraws). Static library size 33 MiB -> 13 MiB.

TODO.md baseline numbers updated to reflect post-fix throughput;
the "Debug-mode lib" finding is folded into the result it produced
rather than left as an open item.
2026-05-15 13:54:48 +01:00
2f109a84fa Stress-test ASCII video at 30/60/120 fps; fix libghostty-vt Debug build
Added a full ASCII-video benchmark suite that hammers the renderer
with 30 KiB / 70 KiB full-screen frames at 30, 60, and 120 fps
targets — both renderer-only and full-pipeline (em.Write + renderer
+ stdout). Each stream benchmark reports µs/frame, fps_ceiling, and
percent of the per-frame budget consumed.

The pipeline benchmarks revealed we were missing 120 fps by a wide
margin (190%-350% of budget at 120fps, 60-90 fps ceiling). Isolating
em.Write confirmed libghostty-vt is the bottleneck — 16-29 ms per
truecolor frame, library file at 33 MiB.

Root cause: the Makefile invoked `zig build` with no
-Doptimize, and Zig's standardOptimizeOption defaults to Debug. So
the shipped libghostty-vt was unoptimised. Fixed by pinning
ReleaseFast in the Makefile (override via GHOSTTY_VT_OPTIMIZE for
debug builds of the upstream lib).

Existing checkouts need `make clean-deps && make deps` to pick up
the rebuild.
2026-05-15 13:43:31 +01:00
1c590f8e32 Concrete perf metrics: live counters in --profile + benchmark suite
Live metrics (--profile):
- New metricsTracker instruments OnPTYOut, viewport renderer,
  stdout writes, libghostty-vt Write/Title CGO calls, sidebar /
  tabbar / status draws (with cache-hit accounting), snapshot
  replays, and the chrome ticker (so we can see ticker fires that
  did nothing).
- Writes metrics.jsonl (one snapshot per second) and metrics.json
  + summary.txt on exit, alongside the existing pprof files.
- All record* methods are nil-safe so disabled paths pay only a
  cheap nil check; counters are atomic so the per-PTY-chunk hot
  path stays lock-free.

Benchmark suite (go test -bench=.):
- Three workload fixtures — plain ASCII, SGR-styled lines, and a
  ratatui-style cursor-shuffling burst — plus a containsOSC
  microbenchmark. Reports ns/op, MB/s, allocs/op, B/op.
- Initial baseline numbers added to TODO under the perf-audit
  section, alongside two new findings (renderer allocs ~1 per 4
  bytes on styled chunks; styled throughput tops out near
  90 MB/s) those benchmarks surfaced.
2026-05-15 13:31:37 +01:00
442eed605c Add auto-generated perf audit findings to TODO
Codebase sweep for perf issues outside the per-PTY-chunk path that
recent CHANGELOG work already covered. Ten findings under a new
"Perf Audit (auto-generated)" section in TODO.md — anchored to
file:line, classified MEDIUM/LOW, with a sketched fix per entry.
None landed as code changes; review pending.
2026-05-15 12:46:42 +01:00
c120342709 Clear TODO backlog: --debug/--profile, codex selection, MCP orientation, perf
- Add --debug[=DIR] / --profile[=DIR] flags that write run artefacts
  (patterm.log, events.jsonl, per-child raw PTY captures, CPU + heap
  + goroutine pprof) to a dir without polluting stdout/stderr.
- Strengthen vendor-TUI orientation in three places (MCP
  initialize.instructions, the spawn_agent tool description, and
  help('spawning')) to head off codex's habits of poking the Unix
  socket via perl and shelling out to launch peers — both bypass
  caller identity and produce orphaned top-level tabs.
- Fix click-and-drag text selection from alt-screen TUIs. Host SGR
  mouse reporting now follows the focused child's screen side
  instead of being permanently armed; alt-screen TUIs that need
  mouse re-enable it themselves and the toggle is forwarded.
- Move drawSidebar() off the per-PTY-chunk hot path. Long claude
  session resume was paying a full sidebar rebuild for every
  scrolled chunk; the chrome ticker now drains a dirty flag at 60 Hz.
- Gate the per-chunk Title() CGO poll on a containsOSC scan so
  codex/ratatui's many SGR-only chunks no longer pay a CGO call each.
2026-05-15 12:41:47 +01:00
01fc108086 Rename Kill to Close, add New Terminal palette entry, clean up exited terminals
- Palette's per-child "Kill <name>" action is now labelled "Close <name>"
  (action kind unchanged; still SIGTERM). Matches the existing "Close
  agent: …" context entry and reads less violent for a graceful term.
- New "New Terminal" palette entry spawns a bare interactive $SHELL pane
  via LaunchTerminal (kind=terminal). Replaces the default "shell"
  process preset that was seeded on first run.
- Exited KindTerminal entries are now dropped from the session in
  reapChild — terminals have no restart path, so leaving them behind as
  greyed rows in the Processes sidebar was just clutter. processList
  also filters defensively.
2026-05-15 11:30:46 +01:00
24696305d6 Merge pull request 'Add idle-state classifier and Solo-parity timer tools' (#3) from feat/idle-detection into main 2026-05-15 11:21:41 +01:00
e657c66dde Merge remote-tracking branch 'origin/main' into feat/idle-detection
# Conflicts:
#	TODO.md
2026-05-15 11:21:28 +01:00
543c7cc59a Fix idle timer review issues 2026-05-15 11:18:03 +01:00
2b9e1ed77c Add idle-state classifier and Solo-parity timer tools
Classifies every running child as idle/working/thinking/permission/error
using one of three pluggable strategies (output_activity,
osc_title_stability, osc_title_status) plus optional regex promoters
applied to the tail of recent output. State and last-match reason are
exposed via MCP on ProcessInfo and get_process_status. Per-preset
configuration lives on a new preset.IdleDetection block with bundled
defaults for the first-party claude/codex/opencode presets.

OSC title plumbing is exposed as Emulator.Title(), polled from the
session pump after each emulator write so title-change activity feeds
into the classifier without an extra cgo callback.

The MCP timer surface expands to match Solo: timer_set,
timer_fire_when_idle_any/all, timer_cancel, timer_pause, timer_resume,
timer_list. timer_wait is now a thin wrapper that shares the same
manager so it shows up in timer_list while pending. Timer bodies are
delivered to the owner process through the existing
InjectAsOrchestrator path. Top-level (non-agent) callers can attach
timers to a specific process via owner_process_id; omitting it grants
universal cancel/pause/resume/list privileges.

The sidebar gains a state glyph per process row and appends a
nearest-timer indicator when one is pending or paused.

Tests: idle_test.go covers the classify() pure function across the
three strategies and regex promotion; timers_test.go covers the
manager. Harness scenarios cover output_activity, osc_title_stability,
osc_title_status, and regex promotion, plus timer_set delivery,
cancel, pause/resume, idle_any-on-transition, idle_all-pending, and
idle_all-already-satisfied. A new wait_until_mcp harness step type
polls an MCP method until an assertion holds.
2026-05-15 09:49:59 +01:00
47 changed files with 4803 additions and 114 deletions

8
.mise.toml Normal file
View File

@@ -0,0 +1,8 @@
# mise config — `mise install` provisions the tools `make deps` needs.
#
# libghostty-vt is built from a pinned upstream Ghostty commit; that
# commit's build.zig.zon pins minimum_zig_version = 0.15.2. We match
# it here so contributors don't have to puzzle out the version from
# a deep upstream file.
[tools]
zig = "0.15.2"

View File

@@ -6,7 +6,109 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.0.2] - 2026-05-15
### Added
- `.mise.toml` pinning `zig = "0.15.2"` (the minimum version the
vendored Ghostty commit requires). Contributors run
`mise install` once; the Makefile picks up the resulting `zig`
binary automatically via `mise which zig` and falls back to
PATH when mise isn't available, so the existing build flow
still works.
- ASCII-video stress benchmarks (`internal/app/bench_test.go`):
per-frame and per-stream variants at 30 / 60 / 120 fps targets,
three workload fixtures (8-colour cells, 24-bit truecolor cells,
and a Bad-Apple-style 1-bit pattern). Each stream benchmark
reports `µs/frame`, an achievable `fps_ceiling`, and `budget_pct`
so you can read off "do we hit N fps?" directly. A matching
Pipeline_ASCIIVideo_* set includes libghostty-vt's em.Write CGO
and an io.Discard stdout write so the FPS claim reflects the
whole pipeline, not just the renderer.
- MCP `initialize.instructions`, the `spawn_agent` tool description
(visible to LLMs via `tools/list`), and the `help('spawning')`
topic now spell out — in the three places vendor TUIs actually
consult — that the connected `patterm` MCP server is the only
correct way to drive the host. Anti-patterns called out by name:
(a) trying to launch `patterm` / `patterm mcp-stdio` themselves,
(b) piping JSON-RPC into the per-PID Unix socket via `perl` /
`nc` / `socat` / `curl`, and (c) shelling out to `claude` /
`codex` / `opencode` to start a peer. Each of those bypasses
caller identity, so a sub-agent spawned that way reads back as
a stray top-level tab instead of a child under the spawning
agent. Codex was hitting (b) and (c) in practice — this is the
fix.
- `--debug[=DIR]` flag captures detailed run artefacts for offline
analysis: a verbose `patterm.log` (the existing `PATTERM_DEBUG_LOG`
stream), an `events.jsonl` lifecycle log (spawn / exit / idle-state
changes with timestamps), and per-child `<id>.raw` files containing
the raw PTY byte stream. With no argument, the dated subdir
`$XDG_STATE_HOME/patterm/debug/YYYYMMDD-HHMMSS` is used; pass an
explicit path to override. All output goes to files — stdout/stderr
are untouched.
- `--profile[=DIR]` flag captures pprof data plus concrete
performance counters for performance work: `cpu.pprof` (running
for the lifetime of the session), plus `heap.pprof` and
`goroutine.pprof` snapshots written on shutdown; alongside them,
a per-hot-path metrics tracker writes `metrics.jsonl` (one row
per second with chunk/byte rates, per-stage mean and max
latencies, and cache hit rates) plus a final `metrics.json`
aggregate and a human-readable `summary.txt` on exit.
Instrumented hot paths: `OnPTYOut`, viewport `renderer.Render`,
host stdout writes, libghostty-vt `emulator.Write` / `Title`,
sidebar / tab bar / status line draws (with cache-hit
accounting), snapshot replays, and the chrome ticker (so you can
see how often it fires with nothing to do). Defaults to
`$XDG_STATE_HOME/patterm/profile/YYYYMMDD-HHMMSS`. All
diagnostics (startup, errors) are written to `profile.log`
inside the dir, never to stdout/stderr.
- Renderer benchmark suite (`internal/app/bench_test.go`). Three
workload fixtures — plain ASCII, SGR-styled lines, and a
ratatui-style cursor-shuffling burst — plus an OSC-gate
micro-benchmark. Run via `go test -bench=. -benchmem
./internal/app/`. Gives a stable reference for the per-chunk
cost of the viewport renderer so future changes can be compared
apples-to-apples.
- "New Terminal" entry in the command palette spawns a bare interactive
`$SHELL` pane (kind `terminal`). Unlike "Run process: …" presets,
which are session-persistent and reachable via `restart_process`,
terminals are ephemeral — once they exit they vanish from the
Processes sidebar instead of lingering as a dead row. The default
`shell` process preset that previously seeded on first run has been
removed; this entry replaces it.
- Per-child idle-state classifier with five states (`idle`, `working`,
`thinking`, `permission`, `error`) and three pluggable strategies:
`output_activity` (claude / opencode defaults), `osc_title_stability`
(codex), and `osc_title_status` (gemini-style status-in-title agents).
Optional `permission_patterns` / `thinking_patterns` / `error_patterns`
regexes promote a base state when matched against the tail of recent
output. State and last-match reason are exposed via MCP on
`ProcessInfo` and `get_process_status` (`idle_state`, `idle_reason`).
- New `idle_detection` block on `preset.Preset` for setting the strategy
threshold, title-to-state map, and promoter regex lists. Bundled
defaults are shipped for the first-party claude / codex / opencode
presets.
- Sidebar now renders a state glyph per process row (`○` idle, `●`
working, `◐` thinking, `?` permission, `✕` error) and, when a process
has a pending or paused timer, appends a nearest-timer indicator
(`⏱ 12s` or `⏸ paused`).
- MCP timer surface expanded to match Solo's tool set: `timer_set`,
`timer_fire_when_idle_any`, `timer_fire_when_idle_all`, `timer_cancel`,
`timer_pause`, `timer_resume`, `timer_list`. Idle-aware timers
registered against already-idle children fire synchronously
(`status: already_satisfied`) for `idle_all`, and report
`already_idle` / `waiting_on` arrays so callers can introspect the
watch set. Timer bodies are delivered to the owner process via the
same orchestrator-injection path as `send_message`.
- Timer tools accept an explicit `owner_process_id` so top-level
(non-agent) callers — including the harness MCP client — can attribute
timers to a specific process. Omitting it treats the caller as the
orchestrator with universal cancel / pause / resume / list privileges.
- libghostty-vt `Title()` accessor on the emulator surface, polled from
the session pump so OSC 0/1/2 title updates feed into the classifier
without a callback round-trip.
- Harness `wait_until_mcp` step type that re-runs an MCP method until an
assertion (Equals / Contains) holds or the timeout elapses. Used by
the new idle / timer scenarios.
- User-created top-level command processes now survive a patterm
restart. Each spawn (palette form, command preset, or MCP
`spawn_process` with `kind=command`) writes a record to
@@ -64,6 +166,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
after a child program disables mouse tracking.
### Changed
- The palette's per-child "Kill <name>" action is now labelled
"Close <name>". The underlying signal (SIGTERM) and behaviour are
unchanged; the new label matches the existing "Close agent: …"
context entry and reads less violent for what is really just a
graceful termination.
- `timer_wait` is now a thin wrapper over the shared timer manager
(`timer_set` semantics). Existing callers see no behavioural change;
the timer is visible in `timer_list` while it's pending.
- CLI flag parsing switched from Go's stdlib `flag` to `spf13/pflag`.
`--project` (and the internal `--socket` / `--identity` /
`--scenario` / `--patterm-bin` flags) are now the only accepted form
@@ -71,6 +181,66 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
renders the canonical `--flag` form.
### Fixed
- `make deps` now builds libghostty-vt with `-Doptimize=ReleaseFast`
instead of zig's silent `Debug` default, and resolves `zig`
through `mise` when a project `.mise.toml` pins it. The
default-Debug build shipped an unoptimised CSI/SGR parser that
ate 16-29 ms per 30-70 KiB full-screen frame in benchmarks,
capping the entire PTY-to-host pipeline at 34-63 fps. After the
rebuild the same pipeline runs at **930-2030 fps**: 27-32× the
prior throughput, and 7-16× margin over 120 fps for full-screen
truecolor ASCII video. Static library size drops from 33 MiB to
13 MiB. Override with `make deps GHOSTTY_VT_OPTIMIZE=Debug` only
when debugging the upstream library itself. Apply on existing
checkouts with `mise install && make clean-deps && make deps`.
- Long claude session resume (and codex steady-state rendering) is
noticeably faster. Two costs that scaled per-PTY-chunk are now
deferred or short-circuited: (1) `drawSidebar()` used to run
synchronously for every chunk that scrolled — on a session
resume where every chunk scrolls, this rebuilt the full sidebar
string hundreds of times for a frame that was almost always
cache-equal. The sidebar now signals dirty and the chrome ticker
(60 Hz) handles the repaint. (2) `pumpChild` polled the
emulator's OSC title after every PTY chunk via CGO, even for
chunks (the common case under codex/ratatui) that carry no OSC
bytes at all. The poll is now gated on a containsOSC scan over
the chunk.
- Click-and-drag text selection from alt-screen TUIs (codex in
particular) now works. Patterm used to keep host SGR mouse
reporting armed continuously, which forced the host terminal to
forward every click as an escape sequence and prevented native
selection. The host's mouse mode now follows the focused child's
screen side: primary-screen children keep mouse armed (so wheel
scrollback works), alt-screen children get host mouse disabled by
default. Alt-screen TUIs that need mouse events (vim, less, etc.)
re-enable mouse-mode themselves; the viewport renderer forwards
those toggles to the host while the child is on alt. Leaving alt
re-arms host mouse reporting so wheel scrollback resumes.
- Exited terminal panes (kind `terminal`, including those launched via
the new "New Terminal" palette entry or MCP `spawn_process` with
`kind=terminal`) are now removed from the session and the Processes
sidebar as soon as they exit. Previously they stuck around as a
greyed-out row indistinguishable from an exited command process,
even though terminals have no restart path.
- `whoami` and `help("timers")` now advertise the full Solo-parity timer
surface (`timer_set`, `timer_fire_when_idle_any`,
`timer_fire_when_idle_all`, `timer_cancel`, `timer_pause`,
`timer_resume`, `timer_list`) so agents using either tool for
orientation discover them — previously only `timer_wait` was listed.
- Resuming a paused idle-aware timer now re-checks the satisfaction
condition. Previously, if every watched process became idle (or, for
`idle_any`, any non-baseline watcher went idle) while the timer was
paused, the timer stayed pending forever because no further state
transitions were observed.
- Fired and canceled timers are now removed from the timer registry,
so long-running patterm sessions no longer accumulate completed
timer records and message bodies. `timer_list` and the sidebar
indicator already filtered them out; only the in-memory leak is
fixed.
- Per-preset idle-detection config is now installed through `SpawnSpec`
before the child is published to the session, closing a race in
which the classifier goroutine could observe a freshly spawned
process before its preset's classifier strategy was attached.
- Opening the command palette while a scratchpad was focused left the
palette wedged — typing did nothing and Esc left the palette's top
border drawn over the pad until you closed the pad with Ctrl-W and

View File

@@ -20,10 +20,30 @@ $(SOURCE)/.git/HEAD:
deps-fetch: $(SOURCE)/.git/HEAD
# Zig's `standardOptimizeOption` defaults to .Debug when no
# -Doptimize is passed, which makes libghostty-vt's CSI/SGR parser
# an order of magnitude slower — truecolor full-screen frames spend
# ~16-29 ms each in em.Write under Debug (see
# internal/app/bench_test.go BenchmarkEmulator_Write_*), which caps
# the full PTY-to-host pipeline at ~60 fps. ReleaseFast is the
# right default for the shipped artefact. Override with
# `make deps GHOSTTY_VT_OPTIMIZE=Debug` when you actually want a
# debug build of the upstream lib.
GHOSTTY_VT_OPTIMIZE ?= ReleaseFast
# Resolve zig via the project's mise pin (.mise.toml) when available,
# falling back to whatever's on PATH. mise keeps the zig version in
# lockstep with what the pinned ghostty commit requires; without it,
# contributors have to chase the version requirement themselves.
ZIG := $(shell command -v mise >/dev/null && mise which zig 2>/dev/null || command -v zig 2>/dev/null)
$(INSTALL)/lib/libghostty-vt.a: $(SOURCE)/.git/HEAD
@command -v zig >/dev/null || { echo "ERROR: zig not on PATH (need >=0.15.2 to build libghostty-vt)"; exit 1; }
@echo ">> building libghostty-vt with zig"
@cd $(SOURCE) && zig build -Demit-lib-vt --prefix $(INSTALL)
@if [ -z "$(ZIG)" ]; then \
echo "ERROR: zig not available. Run \`mise install\` (see .mise.toml — needs zig 0.15.2) or install zig manually."; \
exit 1; \
fi
@echo ">> building libghostty-vt with $(ZIG) (optimize=$(GHOSTTY_VT_OPTIMIZE))"
@cd $(SOURCE) && $(ZIG) build -Demit-lib-vt -Doptimize=$(GHOSTTY_VT_OPTIMIZE) --prefix $(INSTALL)
@test -f $(INSTALL)/lib/libghostty-vt.a || { echo "ERROR: expected static lib at $(INSTALL)/lib/libghostty-vt.a"; exit 1; }
@echo ">> libghostty-vt installed under $(INSTALL)"

170
TODO.md
View File

@@ -1,7 +1,171 @@
- [ ] We should probably rename the Kill <Process> terminology to Close <Process> instead, across processes and agents.
- [ ] Exited shells are still being treated as active processes. They should be removed from the process list when they exit.
- [ ] Shells should be renamed to terminals. "New Terminal" etc.
# Perf Audit (auto-generated 2026-05-15)
Findings from a codebase sweep — not user-reported, needs review before
action. Each item names the anchor and a sketched fix.
Baseline benchmark numbers (`go test -bench=. ./internal/app/`, AMD
Ryzen 7 7800X3D, libghostty-vt **ReleaseFast** after the Makefile
fix landed):
```
# Renderer alone
ViewportRenderer_PlainASCII 229 MB/s 1.3 KB/op 6 allocs/op
ViewportRenderer_StyledLines 89 MB/s 91 KB/op 4325 allocs/op
ViewportRenderer_RatatuiBurst 40 MB/s 365 KB/op 17306 allocs/op
RendererThroughput_ReuseInstance 90 MB/s 316 KB/op 17380 allocs/op
ContainsOSC_NoOSC 3050 MB/s 0 B/op 0 allocs/op
# ASCII-video stream (renderer only — 3 sec at the target fps)
ASCIIVideo_Stream_8Color_120fps 260 µs/frame 3845 fps_ceiling 3.1% budget
ASCIIVideo_Stream_TrueColor_120fps 576 µs/frame 1735 fps_ceiling 6.9% budget
# Full pipeline (em.Write + renderer + io.Discard write)
Pipeline_ASCIIVideo_8Color_120fps 493 µs/frame 2030 fps_ceiling 5.9% budget
Pipeline_ASCIIVideo_TrueColor_120fps 1075 µs/frame 931 fps_ceiling 12.9% budget
# Emulator alone (libghostty-vt CSI/SGR parser)
Emulator_Write_Stream_8Color_120fps 257 µs/frame 3890 fps_ceiling
Emulator_Write_Stream_TrueColor_120fps 488 µs/frame 2051 fps_ceiling
```
Result of the fix below: 27-32× pipeline speedup, 60× emulator
speedup. Pipeline hits 930-2030 fps end-to-end — 7-16× headroom
over the 120 fps target on the heaviest workload (truecolor
full-screen redraws).
- [ ] **viewport renderer allocates ~1 alloc per 4 input bytes on SGR/CSI-heavy chunks.** [MEDIUM]
- `internal/app/viewport_renderer.go` — the styled-lines and
ratatui benchmarks show 4-17k allocs per chunk. The hot
contributors are likely (a) `string(vr.buf)` / `string(params)`
conversions in `emitCSI` for every escape sequence, (b) the
`pending strings.Builder` resizing as fragments arrive, and (c)
`vr.shifter.Shift(vr.buf)` returning a fresh slice per CSI.
- Fix direction: switch CSI param parsing to byte-slice
comparison (no string conversion); reuse `vr.buf` and
`vr.pending` backing arrays across `Render` calls by
pre-growing in `newViewportRenderer`; have `cursorShifter.Shift`
return into a caller-owned buffer instead of allocating.
Profile-guided: run the styled-lines bench, point pprof at the
allocs profile, fix the top three call sites.
- [ ] **viewport renderer throughput (~90 MB/s styled) limits codex steady-state.** [MEDIUM]
- The styled-lines and ratatui benchmarks come in at 89 MB/s and
40 MB/s respectively. A 100 KB/s codex burst is far under that
limit, but a session-resume dump of a 5 MiB chat history takes
50-130 ms of pure renderer time at those rates — enough to be
user-visible at the start of a long resume.
- Fix direction: same as the alloc fix above; once the per-call
allocation cost drops, the throughput ceiling rises with it.
Worth re-running the benches after fixing the allocs and only
investing further if the styled-lines bench is still under
~300 MB/s.
- [ ] **Session.Children() allocates a fresh slice on every call.** [MEDIUM]
- `internal/app/session.go:530-541` walks `s.order` under `s.mu` and
builds a new `[]*Child` slice every time. Callers on hot paths:
`drawSidebar` calls it twice per frame
(`internal/app/sidebar.go:139` and `:171`); `drawTabBar` calls it
once per frame (`internal/app/tabbar.go:37`); the classifier
iterates it every 250 ms (`internal/app/classifier.go:38`); and
palette/navigation hit it on every Ctrl-A/D/W/S keystroke.
- Fix direction: store the snapshot in an `atomic.Pointer[[]*Child]`
on `Session`, refresh it under `s.mu` only when `Spawn` / `delete`
mutates the map. Readers get O(1) `Load()` with zero allocation —
same pattern already used for `listeners` (session.go:118-123).
- [ ] **wait_for_pattern re-scans the entire stream/grid every iteration.** [MEDIUM]
- `internal/app/host.go:476-493` (the `check` closure). On `scope =
"scrollback"` it calls `c.StreamRead(0)` followed by
`stripANSIBytes(nil, b)` over the entire ring on every wake — a
full O(ring size) walk per chunk arrival. On `grid` it goes
through PlainText (one CGO call) plus a regex match against the
full grid string. For an agent waiting on a marker in a chatty
pane, every PTY chunk fires `check()`.
- Fix direction: for `scrollback`, track the offset of the last
check and run the regex only over the new tail, reusing a
per-call scratch buffer for ANSI stripping. For `grid`, dedupe
on `ScreenVersion()` — skip when version hasn't changed.
- [ ] **search_output compiles regex + strips ANSI on every call.** [MEDIUM]
- `internal/app/host.go:428` compiles a fresh `regexp.Regexp` per
invocation; `:434` strips ANSI over the entire ring buffer when
`kind="rendered"`. Agents that poll `search_output` with the same
pattern (the typical "watch for marker" loop) repay both costs on
every call.
- Fix direction: small LRU of compiled regexes keyed by pattern
string (cap maybe 32) on `toolHost`. Cache the stripped-ANSI
buffer keyed by `c.ScreenVersion()` so consecutive searches over
an unchanged ring reuse the strip.
- [ ] **GetProcessOutput grid mode acquires the emulator twice.** [MEDIUM]
- `internal/app/host.go:375-391` does `em := c.Emulator()` for
ActiveScreen / Cursor / Size, then at line 387 re-fetches
`em := c.Emulator()` for PlainText. Each `Emulator()` call goes
through `ptyMu` and inspects the live PTY pointer. Under a
chatty agent polling `get_process_output` every 100 ms this is
a redundant lock and pointer chase per call.
- Fix direction: hold the emulator reference from the first
lookup; reuse it for PlainText. The check `if em == nil` still
runs cleanly because the variable is captured.
- [ ] **FindChildByIdentity is O(N) under the session lock.** [LOW]
- `internal/app/session.go:553-565` scans the children map looking
for a matching `Identity` token on every new mcp-stdio
connection. Not a steady-state hot path — only fires once per
child spawn — but with many short-lived sub-agents it adds up
and contends with everyone else taking `s.mu`.
- Fix direction: maintain an `identityIndex map[string]string`
(identity → child id) updated alongside spawn / exit, give the
lookup an O(1) read.
- [ ] **Per-promoter regex matches in the idle classifier.** [LOW]
- `internal/app/idle.go:175-182` (`matchAny`) walks each compiled
pattern and runs the DFA over the same 4 KiB tail. A preset with
five permission patterns + five error patterns is ten DFA
invocations per child per 250 ms tick.
- Fix direction: at preset load time, compile each `_patterns`
list into a single alternation regex (`(?:p1)|(?:p2)|…`). The
classifier then makes one Match call per category per tick.
- [ ] **Port-detection dedup is O(N²) over c.ports.** [LOW]
- `internal/app/child.go:461-467`: for each fresh URL match the
code linearly scans the existing port list. The list rarely
grows past a handful, but a dev server that lists "all open
ports" in one log line interacts badly: M new matches × N
existing entries.
- Fix direction: keep a `seenPorts map[int]struct{}` next to
`c.ports`, rebuilt on prune (none today). O(1) per match.
- [ ] **Port-sighting string allocations happen before the dedup check.** [LOW]
- `internal/app/child.go:455-456` allocates `urlForm` and `portStr`
before line 461's `seen` walk. Both strings are wasted when the
port is already in `c.ports`. Inside `c.portsMu` for the whole
loop body too, blocking the `Ports()` reader path.
- Fix direction: bind the port int first (cheap parse from
`m[1]`), do the seen check, only then allocate the URL string
for the surviving sighting.
- [ ] **classifier `time.Now()` syscall per child per tick.** [LOW]
- `internal/app/classifier.go:54` (and the `IdleMS` /
`TitleIdleMS` helpers it transitively calls in
`internal/app/child.go:343-374`) each call `time.Now()`.
Reading time on Linux is fast (vDSO) but with N children × 4
`time.Now()` per tick × 4 ticks/sec it's wasted work that can
be batched.
- Fix direction: capture `now := time.Now().UnixNano()` once at
the top of `classifyAll` and thread it into `classifyOne` and
the helpers as a parameter.
- [ ] **wait_for_pattern subscribes a listener for every call.** [LOW]
- `internal/app/host.go:472-474`: each invocation calls
`Session.Subscribe(wake)` which clones the listener slice and
swaps the atomic pointer; the `defer Unsubscribe` does the same
on exit. Two allocations per `wait_for_pattern`. The agent
pattern of looping on `wait_for_pattern` after every tool call
pays this churn on the steady-state path.
- Fix direction: a per-child `chunkBroadcaster` registered once
at child spawn that hands out lightweight subscriber tokens,
rather than going through the full session listener machinery.
# On Hold
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD]

View File

@@ -16,7 +16,10 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"runtime/pprof"
"time"
flag "github.com/spf13/pflag"
@@ -49,7 +52,13 @@ func main() {
var (
projectDir = flag.String("project", "", "project directory (default $PWD)")
showVersion = flag.Bool("version", false, "print version and exit")
debugDir = flag.String("debug", "", "write debug logs + per-child raw PTY output to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/debug when DIR is omitted)")
profileDir = flag.String("profile", "", "write pprof files (cpu/heap/goroutine) and live perf counters (metrics.jsonl per-second, metrics.json + summary.txt on exit) to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/profile when DIR is omitted)")
)
// Allow bare `--debug` / `--profile` with no value — pflag treats
// them as boolean-shaped strings, picking a sensible default dir.
flag.Lookup("debug").NoOptDefVal = "auto"
flag.Lookup("profile").NoOptDefVal = "auto"
flag.Parse()
if *showVersion {
@@ -73,15 +82,104 @@ func main() {
die("chdir %s: %v", cwd, err)
}
resolvedDebug, err := resolveDiagDir(*debugDir, "debug")
if err != nil {
die("debug: %v", err)
}
resolvedProfile, err := resolveDiagDir(*profileDir, "profile")
if err != nil {
die("profile: %v", err)
}
stopProfile := startProfile(resolvedProfile)
defer stopProfile()
ctx := context.Background()
if err := app.Run(ctx, app.Options{
ProjectDir: cwd,
ProjectKey: key,
DebugDir: resolvedDebug,
ProfileDir: resolvedProfile,
}); err != nil {
die("%v", err)
}
}
// resolveDiagDir turns the raw flag value into an absolute directory
// path. Empty string disables the feature. The sentinel "auto" (set by
// NoOptDefVal on bare flags) picks $XDG_STATE_HOME/patterm/<kind>/<ts>.
// Any other value is treated as a literal path.
func resolveDiagDir(raw, kind string) (string, error) {
if raw == "" {
return "", nil
}
if raw == "auto" {
base := os.Getenv("XDG_STATE_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".local", "state")
}
ts := time.Now().Format("20060102-150405")
return filepath.Join(base, "patterm", kind, ts), nil
}
return raw, nil
}
// startProfile begins a CPU profile under dir and returns a stop func
// that writes heap + goroutine snapshots before flushing the CPU file.
// Returns a no-op stop func when dir is empty. All diagnostics are
// written to <dir>/profile.log — never to stdout/stderr — so the TUI
// stays uncluttered.
func startProfile(dir string) func() {
if dir == "" {
return func() {}
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return func() {}
}
logPath := filepath.Join(dir, "profile.log")
plog := func(format string, args ...any) {
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, format+"\n", args...)
}
cpuPath := filepath.Join(dir, "cpu.pprof")
f, err := os.Create(cpuPath)
if err != nil {
plog("cpu open: %v", err)
return func() {}
}
if err := pprof.StartCPUProfile(f); err != nil {
plog("cpu start: %v", err)
_ = f.Close()
return func() {}
}
plog("profiling started at %s", time.Now().Format(time.RFC3339Nano))
return func() {
pprof.StopCPUProfile()
_ = f.Close()
// Heap and goroutine snapshots at exit. Heap captures
// steady-state allocation; goroutine catches stragglers
// that didn't get cleaned up.
runtime.GC()
if hf, err := os.Create(filepath.Join(dir, "heap.pprof")); err == nil {
_ = pprof.Lookup("heap").WriteTo(hf, 0)
_ = hf.Close()
}
if gf, err := os.Create(filepath.Join(dir, "goroutine.pprof")); err == nil {
_ = pprof.Lookup("goroutine").WriteTo(gf, 0)
_ = gf.Close()
}
plog("profiling stopped at %s", time.Now().Format(time.RFC3339Nano))
}
}
func runMCPProxy() {
var (
socket = flag.String("socket", "", "path to the running patterm process's MCP socket")

View File

@@ -29,6 +29,17 @@ import (
type Options struct {
ProjectDir string
ProjectKey string
// DebugDir, when non-empty, enables verbose debug logging to
// <DebugDir>/patterm.log and per-child raw PTY output capture to
// <DebugDir>/<child-id>.raw. The dir is created if missing. Events
// (spawn / exit / state change) land in <DebugDir>/events.jsonl.
DebugDir string
// ProfileDir, when non-empty, enables in-process performance
// counters. patterm writes a per-second JSONL snapshot stream to
// <ProfileDir>/metrics.jsonl, a final aggregate to metrics.json,
// and a human-readable summary.txt on shutdown. The pprof files
// written by --profile sit alongside these in the same dir.
ProfileDir string
}
const keyCtrlK byte = 0x0b
@@ -77,6 +88,22 @@ func Run(ctx context.Context, opts Options) error {
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
defer sess.Shutdown()
// Debug capture: when --debug=<dir> is set, write a verbose log
// (patterm.log), per-child raw PTY output (<id>.raw), and a
// JSONL event stream (events.jsonl). Installed before the TUI
// listener so the very first OnChildSpawned / OnPTYOut event
// is captured.
if opts.DebugDir != "" {
dc, err := openDebugCapture(opts.DebugDir)
if err != nil {
return fmt.Errorf("app: debug capture: %w", err)
}
os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath())
sess.Subscribe(dc)
defer dc.Close()
logf("debug capture enabled at %s", opts.DebugDir)
}
// Snapshot persisted processes BEFORE attaching the store: Spawn
// mints fresh ids, so the old records would otherwise linger
// alongside the new ones. Drop them up front; the restore loop
@@ -113,6 +140,23 @@ func Run(ctx context.Context, opts Options) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Performance tracker — instrumented hot-path timings written to
// <ProfileDir>. nil when --profile is off, in which case every
// record*() call is a fast nil check.
metrics, err := newMetricsTracker(opts.ProfileDir)
if err != nil {
return fmt.Errorf("app: metrics tracker: %w", err)
}
if metrics != nil {
go metrics.run(ctx)
defer metrics.close()
}
// Per-session idle-detection classifier. One goroutine ticks every
// 250ms over every live child and updates IdleState. It stops when
// ctx is cancelled.
go sess.runClassifier(ctx)
st := &uiState{
sess: sess,
presets: presets,
@@ -120,10 +164,13 @@ func Run(ctx context.Context, opts Options) error {
pads: pads,
chromeWake: make(chan struct{}, 1),
trust: trustStore,
timers: host.timers,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
metrics: metrics,
}
sess.SetMetrics(metrics)
host.attention = st
host.focus = st
host.prompter = st
@@ -242,11 +289,20 @@ func Run(ctx context.Context, opts Options) error {
case <-st.chromeWake:
case <-ticker.C:
}
if !st.chromeDirty.Swap(false) {
chromeChanged := st.chromeDirty.Swap(false)
sidebarChanged := st.sidebarDirty.Swap(false)
didWork := chromeChanged || sidebarChanged
st.metrics.recordTickerFire(didWork)
if !didWork {
continue
}
st.drawTabBar()
st.drawStatusLine()
if chromeChanged {
st.drawTabBar()
st.drawStatusLine()
}
if sidebarChanged {
st.drawSidebar()
}
}
}()
@@ -296,6 +352,7 @@ type uiState struct {
launcher *Launcher
pads *scratchpad.Store
trust *trust.Store
timers *timerManager
outMu sync.Mutex
@@ -348,6 +405,11 @@ type uiState struct {
hostCols, hostRows uint16
stdinTTY bool
// metrics is the optional performance tracker. nil when --profile
// is off. Hot paths call metrics.recordX which is a fast nil
// check on the disabled path.
metrics *metricsTracker
// chromeCacheMu guards the last-rendered byte cache for each chrome
// element. The tab bar, sidebar, and status line all repaint on
// many state changes and on every PTY chunk, but their content
@@ -365,7 +427,14 @@ type uiState struct {
// sensitive paths (owner flip, attention, trust, focus change)
// continue to call drawStatusLine / drawTabBar synchronously.
chromeDirty atomic.Bool
chromeWake chan struct{}
// sidebarDirty defers sidebar repaints off the per-chunk hot path
// in the same way. A long claude session resume — where every PTY
// chunk scrolls the viewport — used to call drawSidebar()
// synchronously per chunk, which dominated the resume's wall time
// (hundreds of full-sidebar rebuilds for a frame that was almost
// always cache-equal).
sidebarDirty atomic.Bool
chromeWake chan struct{}
// padsCacheMu guards the cached scratchpad listing. The sidebar
// and palette/sidebar nav helpers read it on every chunk-driven
@@ -408,14 +477,18 @@ func (st *uiState) focusProcess(processID string) {
return
}
layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c)
st.mu.Lock()
leavingPad := st.focusedPad != ""
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout)
r := newViewportRenderer(layout)
r.SetChildOnAlt(onAlt)
st.renderer = r
st.mu.Unlock()
st.syncHostMouseForChild(onAlt)
// Wipe whatever the previous focus (PTY child or pad view) left in
// the viewport before painting the new child's snapshot.
if leavingPad {
@@ -427,6 +500,41 @@ func (st *uiState) focusProcess(processID string) {
st.drawStatusLine()
}
// childIsOnAlt reports whether the child's emulator is currently on
// its alternate screen. Returns false if the emulator is gone or the
// query fails.
func childIsOnAlt(c *Child) bool {
if c == nil {
return false
}
em := c.Emulator()
if em == nil {
return false
}
sc, err := em.ActiveScreen()
if err != nil {
return false
}
return sc == vt.ScreenAlternate
}
// syncHostMouseForChild emits the host mouse-reporting toggle that
// matches a newly-focused child's screen side. Primary-screen children
// want host mouse armed so the wheel drives inline scrollback; alt-
// screen children get host mouse disabled by default so click-and-drag
// selection works. Alt-screen TUIs that need mouse (vim, ranger, etc.)
// re-enable it themselves, and the viewport renderer forwards those
// toggles back to the host.
func (st *uiState) syncHostMouseForChild(onAlt bool) {
st.outMu.Lock()
defer st.outMu.Unlock()
if onAlt {
_, _ = os.Stdout.WriteString("\x1b[?1000l\x1b[?1006l")
} else {
_, _ = os.Stdout.WriteString("\x1b[?1000h\x1b[?1006h")
}
}
// focusScratchpad shifts focus to a scratchpad. The main viewport
// renders the pad's text instead of any child PTY; PTY output for the
// previously focused child is dropped until focus moves back to a
@@ -565,12 +673,14 @@ func (st *uiState) scratchpadsChanged() {
// OnChildSpawned auto-focuses the new child.
func (st *uiState) OnChildSpawned(c *Child) {
layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c)
st.mu.Lock()
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
renderer := newViewportRenderer(layout)
renderer.SetChildOnAlt(onAlt)
st.renderer = renderer
palOpen := st.palette != nil
if palOpen {
@@ -604,12 +714,21 @@ func (st *uiState) OnChildSpawned(c *Child) {
st.outMu.Unlock()
}
st.syncHostMouseForChild(onAlt)
st.moveToViewportOrigin()
st.drawTabBar()
st.drawSidebar()
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.
func (st *uiState) OnChildStateChanged(string, IdleState) {
st.drawSidebar()
}
// OnChildExited drops focus and shows the empty state if it was the
// focused child.
func (st *uiState) OnChildExited(c *Child) {
@@ -695,6 +814,10 @@ func (st *uiState) scheduleAutoRestart(c *Child) {
// disabled only around the replay so long styled runs cannot wrap into
// the right rail.
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
var entry time.Time
if st.metrics != nil {
entry = time.Now()
}
layout := st.layoutSnapshot()
st.mu.Lock()
focus := st.focusedID
@@ -711,16 +834,31 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
}
st.mu.Unlock()
if palOpen || focus != childID || renderer == nil {
st.metrics.recordPTYOutDrop()
return
}
var out []byte
if forceRepaint {
var snapStart time.Time
if st.metrics != nil {
snapStart = time.Now()
}
out = st.renderFocusedSnapshot(childID, renderer, layout)
if st.metrics != nil {
st.metrics.recordSnapshot(time.Since(snapStart))
}
if len(out) == 0 {
return
}
} else {
var rstart time.Time
if st.metrics != nil {
rstart = time.Now()
}
out = renderer.Render(chunk)
if st.metrics != nil {
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
@@ -730,9 +868,16 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
var wstart time.Time
if st.metrics != nil {
wstart = time.Now()
}
st.outMu.Lock()
_, _ = os.Stdout.Write(wrapped)
st.outMu.Unlock()
if st.metrics != nil {
st.metrics.recordStdout(time.Since(wstart), len(wrapped))
}
// RI / IND / NEL / SU / SD / IL / DL and bottom-margin LF / VT / FF
// scroll content within the host's scroll region, which spans every
// column — so any of them drags the right-hand sidebar's session-tree
@@ -745,15 +890,23 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
// Scrolled chunks can clobber the sidebar columns; repaint
// synchronously so the gap fills before the next chunk lands.
st.drawSidebar()
// Defer the sidebar repaint to the chrome ticker. On a long
// session resume every PTY chunk scrolls, and a synchronous
// drawSidebar() per chunk dominates wall time even when the
// frame ends up cache-equal — the rebuild work is unconditional.
// The chrome ticker drains the dirty flag at ~60 Hz, so the
// visible gap a scrolled chunk can leave in the sidebar columns
// is bounded by one frame.
st.markSidebarDirty()
}
// Defer the tab bar + status line repaint to the chrome ticker.
// The cached frame already short-circuits the wire write, but
// avoiding the string build, FindChild, and locking on every
// chunk pulls steady-state CPU off the hot path.
st.markChromeDirty()
if st.metrics != nil {
st.metrics.recordPTYOut(time.Since(entry), len(chunk))
}
}
func (st *uiState) enterScreen() {
@@ -851,6 +1004,18 @@ func (st *uiState) markChromeDirty() {
}
}
// markSidebarDirty schedules a sidebar repaint on the next ticker
// frame. Hot path — every scrolled PTY chunk lands here. Synchronous
// repaints from latency-sensitive sites (spawn, exit, focus, state
// change, trust) keep calling drawSidebar directly.
func (st *uiState) markSidebarDirty() {
st.sidebarDirty.Store(true)
select {
case st.chromeWake <- struct{}{}:
default:
}
}
func (st *uiState) invalidateChromeCache() {
st.chromeCacheMu.Lock()
st.tabBarCache = ""
@@ -881,6 +1046,10 @@ func (st *uiState) renderPaletteLocked() {
// attention ask. Right side: palette hint. The PTY child occupies
// host_rows-1 rows so this row is exclusively ours.
func (st *uiState) drawStatusLine() {
var entry time.Time
if st.metrics != nil {
entry = time.Now()
}
st.mu.Lock()
palOpen := st.palette != nil
focusID := st.focusedID
@@ -967,10 +1136,16 @@ func (st *uiState) drawStatusLine() {
st.chromeCacheMu.Lock()
if line == st.statusLineCache {
st.chromeCacheMu.Unlock()
if st.metrics != nil {
st.metrics.recordStatus(time.Since(entry), true)
}
return
}
st.statusLineCache = line
st.chromeCacheMu.Unlock()
if st.metrics != nil {
defer func() { st.metrics.recordStatus(time.Since(entry), false) }()
}
st.outMu.Lock()
defer st.outMu.Unlock()
@@ -1607,6 +1782,13 @@ func (st *uiState) closePalette(action paletteAction) {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "spawn-terminal":
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchTerminal(nil, "terminal", "", "", nil); err != nil {
st.flashError(fmt.Sprintf("spawn terminal: %v", err))
}
case "spawn-process-submit":
if action.command == "" {
restoreView()

546
internal/app/bench_test.go Normal file
View File

@@ -0,0 +1,546 @@
package app
import (
"fmt"
"io"
"strings"
"testing"
"github.com/hjbdev/patterm/internal/vt"
)
// Benchmarks for patterm's hot paths. Run with:
//
// go test -bench=. -benchmem ./internal/app/
//
// or target one:
//
// go test -bench=BenchmarkViewportRenderer_PlainASCII -benchmem ./internal/app/
//
// The fixtures below model the three workloads we care about most:
//
// - PlainASCII: long-running text output (claude streaming a code
// diff, codex outputting a tool result body). Fast-path territory.
// - StyledLines: SGR-heavy output (claude/codex chat history with
// coloured tokens). State-machine path.
// - RatatuiBurst: many short cursor-positioning / SGR transitions in
// a tight chunk, matching codex/ratatui's incremental diff
// updates.
// - SnapshotReplay: full styled-grid replay (focus switch).
// buildPlainASCIIChunk returns a roughly N-byte chunk of pure
// printable ASCII text with the occasional newline — the cheapest
// workload, exercises the fast path in viewport_renderer.Render.
func buildPlainASCIIChunk(n int) []byte {
var b strings.Builder
b.Grow(n)
line := "The quick brown fox jumps over the lazy dog 0123456789 "
for b.Len() < n {
b.WriteString(line)
if b.Len()%80 < len(line) {
b.WriteByte('\n')
}
}
return []byte(b.String()[:n])
}
// buildStyledLinesChunk simulates SGR-heavy output: every word wears
// a colour, so the renderer breaks out of its fast path on every
// escape sequence.
func buildStyledLinesChunk(n int) []byte {
var b strings.Builder
b.Grow(n)
colours := []string{"31", "32", "33", "34", "35", "36"}
words := []string{"package", "func", "return", "import", "struct", "type", "const", "var"}
i := 0
for b.Len() < n {
fmt.Fprintf(&b, "\x1b[%sm%s\x1b[0m ", colours[i%len(colours)], words[i%len(words)])
if i%10 == 9 {
b.WriteByte('\n')
}
i++
}
return []byte(b.String()[:n])
}
// buildRatatuiBurst simulates a single ratatui-style diff frame:
// CUP, SGR, a few chars, CUP, SGR, a few chars… for a viewport's
// worth of cells.
func buildRatatuiBurst(cells int) []byte {
var b strings.Builder
for i := 0; i < cells; i++ {
row := (i / 80) + 1
col := (i % 80) + 1
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[3%dm%c", row, col, i%8, byte('A'+(i%26)))
}
b.WriteString("\x1b[0m")
return []byte(b.String())
}
// BenchmarkViewportRenderer_PlainASCII drives a 16 KiB plain-text
// chunk through Render once per iteration. Reports ns/op,
// allocations, and B/op.
func BenchmarkViewportRenderer_PlainASCII(b *testing.B) {
chunk := buildPlainASCIIChunk(16 * 1024)
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(chunk)
}
}
// BenchmarkViewportRenderer_StyledLines exercises the per-byte CSI
// path on SGR-heavy output. Most claude/codex chat resume traffic
// looks like this — coloured prose with frequent style toggles.
func BenchmarkViewportRenderer_StyledLines(b *testing.B) {
chunk := buildStyledLinesChunk(16 * 1024)
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(chunk)
}
}
// BenchmarkViewportRenderer_RatatuiBurst measures the worst-case
// cursor-shuffling workload: full-frame diff updates dominated by
// CUP + SGR + single-char writes.
func BenchmarkViewportRenderer_RatatuiBurst(b *testing.B) {
chunk := buildRatatuiBurst(80 * 24) // one screenful of cells
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(chunk)
}
}
// BenchmarkContainsOSC measures the OSC-gate fast path used by
// pumpChild before deciding whether to fire the per-chunk Title()
// CGO call. Inputs:
// - "hot": SGR-styled output without OSC — the common case for
// codex/ratatui. We want this near zero.
// - "cold": chunk with an OSC sequence in the middle.
func BenchmarkContainsOSC_NoOSC(b *testing.B) {
chunk := buildStyledLinesChunk(8 * 1024)
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = containsOSC(chunk)
}
}
func BenchmarkContainsOSC_WithOSC(b *testing.B) {
chunk := append(buildStyledLinesChunk(8*1024), []byte("\x1b]0;new title\x07")...)
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = containsOSC(chunk)
}
}
// BenchmarkRendererThroughput_ReuseInstance approximates real
// session behaviour: a single viewport renderer fed many chunks in
// sequence, no per-iteration allocation. Reports a throughput
// closer to the steady-state OnPTYOut path. Chunks are 4 KiB to
// match typical PTY read sizes; the renderer is reset every
// benchmark run.
func BenchmarkRendererThroughput_ReuseInstance(b *testing.B) {
chunks := make([][]byte, 16)
for i := range chunks {
chunks[i] = buildStyledLinesChunk(4 * 1024)
}
totalBytes := 0
for _, c := range chunks {
totalBytes += len(c)
}
b.SetBytes(int64(totalBytes))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
for _, c := range chunks {
_ = vr.Render(c)
}
}
}
// Stress workloads — these model the worst things a real session
// can throw at us. The headline target is "ASCII video": every cell
// of an 80x40 viewport carries an SGR colour change and a printable
// character, rendered as one chunk per frame. Real ASCII-video CLIs
// (ascii-image-converter, asciinema-render, towel.blinkenlights, the
// Bad Apple meme) hit patterm with exactly this pattern at 24-30 fps
// for minutes at a time.
//
// We synthesise the workload rather than ship a captured corpus so
// the benchmarks stay deterministic and the repo doesn't carry tens
// of MiB of fixture data. The encoding is faithful to what those
// tools actually emit.
// buildASCIIVideoFrame builds a single full-viewport frame with
// 8-colour SGR per cell (`\x1b[3Nm`). One frame ≈ 30 KiB for an
// 80x40 viewport, which lines up with what ascii-video tools emit.
func buildASCIIVideoFrame(cols, rows int) []byte {
var b strings.Builder
b.WriteString("\x1b[H") // home cursor before the frame starts
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
fmt.Fprintf(&b, "\x1b[3%dm%c", (r+c)%8, byte(' '+(r*c)%(0x7e-' ')))
}
b.WriteString("\x1b[0m\r\n")
}
return []byte(b.String())
}
// buildASCIIVideoFrameTrueColor builds the same frame but with
// 24-bit RGB SGR (`\x1b[38;2;R;G;Bm`). Every cell is ~20 bytes of
// escape + 1 byte glyph, so a frame is ≈ 70 KiB. This is what
// chafa --colors=full and modern terminal video players emit, and
// it's the heaviest SGR variant the renderer's CSI path sees.
func buildASCIIVideoFrameTrueColor(cols, rows int) []byte {
var b strings.Builder
b.WriteString("\x1b[H")
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
rd := (r * 7) % 256
gd := (c * 11) % 256
bd := ((r + c) * 13) % 256
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm%c", rd, gd, bd, byte(' '+(r*c)%(0x7e-' ')))
}
b.WriteString("\x1b[0m\r\n")
}
return []byte(b.String())
}
// buildBadApplePattern builds the simplest possible ASCII video
// frame: alternating black/white cells (the Bad Apple meme is
// essentially a 1-bit silhouette video). This is the pattern that
// stresses the SGR state-machine without exercising truecolor parse
// — useful for isolating "is the cost in the colour parsing or in
// the cell-by-cell switching?"
func buildBadApplePattern(cols, rows int) []byte {
var b strings.Builder
b.WriteString("\x1b[H")
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
if (r+c)%2 == 0 {
b.WriteString("\x1b[37m█")
} else {
b.WriteString("\x1b[30m█")
}
}
b.WriteString("\x1b[0m\r\n")
}
return []byte(b.String())
}
// BenchmarkASCIIVideo_Frame_8Color renders a single full-screen
// frame as one chunk. The headline number is MB/s — at 30 fps a
// frame is one PTY chunk every ~33 ms, so this should comfortably
// stay well under 1 ms.
func BenchmarkASCIIVideo_Frame_8Color(b *testing.B) {
frame := buildASCIIVideoFrame(80, 40)
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(frame)
}
}
// BenchmarkASCIIVideo_Frame_TrueColor renders a single truecolor
// frame. ~70 KiB per frame. Compare this to the 8-colour number to
// see how much extra cost the truecolor SGR parse imposes — the
// `\x1b[38;2;R;G;Bm` form is the longest and most parameter-rich
// CSI patterm sees in practice.
func BenchmarkASCIIVideo_Frame_TrueColor(b *testing.B) {
frame := buildASCIIVideoFrameTrueColor(80, 40)
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(frame)
}
}
// BenchmarkASCIIVideo_Frame_BadApple is the 1-bit pattern: simplest
// SGR (two colours, alternating). Isolates the renderer's cell-by-
// cell SGR cycling cost from the truecolor parse cost.
func BenchmarkASCIIVideo_Frame_BadApple(b *testing.B) {
frame := buildBadApplePattern(80, 40)
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(frame)
}
}
// runStreamBench is the shared body for the per-fps stream
// benchmarks. It feeds a fixed frame N times through a single
// renderer instance and reports µs/frame + an achievable-fps
// ceiling alongside the standard ns/op + MB/s. The fps value in
// the benchmark name is the *target* — the workload itself doesn't
// rate-limit; we just decide how many frames make a benchmark op
// (3 seconds' worth) so steady-state cost dominates warm-up.
func runStreamBench(b *testing.B, frame []byte, fps int) {
frames := fps * 3 // 3 seconds at the target rate
totalBytes := int64(len(frame) * frames)
b.SetBytes(totalBytes)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
for f := 0; f < frames; f++ {
_ = vr.Render(frame)
}
}
nsPerFrame := float64(b.Elapsed().Nanoseconds()) / float64(b.N*frames)
b.ReportMetric(nsPerFrame/1000.0, "µs/frame")
b.ReportMetric(1e9/nsPerFrame, "fps_ceiling")
// budget_pct = how much of the per-frame budget at the target
// rate we burn. Under 100 means we can hit the target; over
// means we can't.
budgetNs := 1e9 / float64(fps)
b.ReportMetric(nsPerFrame/budgetNs*100, "budget_pct")
}
// BenchmarkASCIIVideo_Stream_8Color_30fps / _60fps / _120fps reuse
// one renderer across (3 × fps) frames. The headline numbers are
// µs/frame, fps_ceiling (= 1e9 / ns/frame), and budget_pct (=
// percent of the per-frame budget at the target rate we consume).
//
// 30 fps is the typical ASCII-video baseline (towel, chafa, Bad
// Apple ports). 60 is the "smooth playback" target. 120 is a
// future-proofing stress level matching modern high-refresh
// terminals.
func BenchmarkASCIIVideo_Stream_8Color_30fps(b *testing.B) {
runStreamBench(b, buildASCIIVideoFrame(80, 40), 30)
}
func BenchmarkASCIIVideo_Stream_8Color_60fps(b *testing.B) {
runStreamBench(b, buildASCIIVideoFrame(80, 40), 60)
}
func BenchmarkASCIIVideo_Stream_8Color_120fps(b *testing.B) {
runStreamBench(b, buildASCIIVideoFrame(80, 40), 120)
}
// BenchmarkASCIIVideo_Stream_TrueColor_* same set but with the
// truecolor frames. Compare against the 8-colour numbers to see
// what the longer `\x1b[38;2;R;G;Bm` parse costs us.
func BenchmarkASCIIVideo_Stream_TrueColor_30fps(b *testing.B) {
runStreamBench(b, buildASCIIVideoFrameTrueColor(80, 40), 30)
}
func BenchmarkASCIIVideo_Stream_TrueColor_60fps(b *testing.B) {
runStreamBench(b, buildASCIIVideoFrameTrueColor(80, 40), 60)
}
func BenchmarkASCIIVideo_Stream_TrueColor_120fps(b *testing.B) {
runStreamBench(b, buildASCIIVideoFrameTrueColor(80, 40), 120)
}
// BenchmarkASCIIVideo_Stream_BadApple_* tracks the 1-bit alternating
// pattern. Isolates per-cell SGR cycling cost from the truecolor
// parse cost above — useful when reading the diff between the two
// stream variants.
func BenchmarkASCIIVideo_Stream_BadApple_30fps(b *testing.B) {
runStreamBench(b, buildBadApplePattern(80, 40), 30)
}
func BenchmarkASCIIVideo_Stream_BadApple_60fps(b *testing.B) {
runStreamBench(b, buildBadApplePattern(80, 40), 60)
}
func BenchmarkASCIIVideo_Stream_BadApple_120fps(b *testing.B) {
runStreamBench(b, buildBadApplePattern(80, 40), 120)
}
// BenchmarkEmulator_Write_8Color / _TrueColor isolate the
// libghostty-vt CGO cost — same frames the Pipeline benchmarks use,
// but feeding only the emulator. The delta between this and
// BenchmarkASCIIVideo_Stream_… is the renderer's share; the rest
// is libghostty-vt.
func BenchmarkEmulator_Write_8Color_Frame(b *testing.B) {
frame := buildASCIIVideoFrame(80, 40)
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
em, err := vt.NewGhosttyEmulator(80, 40)
if err != nil {
b.Fatalf("emulator: %v", err)
}
if _, werr := em.Write(frame); werr != nil {
b.Fatalf("emulator.Write: %v", werr)
}
_ = em.Close()
}
}
func BenchmarkEmulator_Write_TrueColor_Frame(b *testing.B) {
frame := buildASCIIVideoFrameTrueColor(80, 40)
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
em, err := vt.NewGhosttyEmulator(80, 40)
if err != nil {
b.Fatalf("emulator: %v", err)
}
if _, werr := em.Write(frame); werr != nil {
b.Fatalf("emulator.Write: %v", werr)
}
_ = em.Close()
}
}
// BenchmarkEmulator_Write_Stream_120fps reuses one emulator across
// 360 frames (3 sec × 120 fps). This is the cleanest measurement
// of em.Write steady-state cost.
func BenchmarkEmulator_Write_Stream_8Color_120fps(b *testing.B) {
frame := buildASCIIVideoFrame(80, 40)
const frames = 360
b.SetBytes(int64(len(frame) * frames))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
em, err := vt.NewGhosttyEmulator(80, 40)
if err != nil {
b.Fatalf("emulator: %v", err)
}
for f := 0; f < frames; f++ {
if _, werr := em.Write(frame); werr != nil {
b.Fatalf("emulator.Write: %v", werr)
}
}
_ = em.Close()
}
nsPerFrame := float64(b.Elapsed().Nanoseconds()) / float64(b.N*frames)
b.ReportMetric(nsPerFrame/1000.0, "µs/frame")
b.ReportMetric(1e9/nsPerFrame, "fps_ceiling")
}
func BenchmarkEmulator_Write_Stream_TrueColor_120fps(b *testing.B) {
frame := buildASCIIVideoFrameTrueColor(80, 40)
const frames = 360
b.SetBytes(int64(len(frame) * frames))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
em, err := vt.NewGhosttyEmulator(80, 40)
if err != nil {
b.Fatalf("emulator: %v", err)
}
for f := 0; f < frames; f++ {
if _, werr := em.Write(frame); werr != nil {
b.Fatalf("emulator.Write: %v", werr)
}
}
_ = em.Close()
}
nsPerFrame := float64(b.Elapsed().Nanoseconds()) / float64(b.N*frames)
b.ReportMetric(nsPerFrame/1000.0, "µs/frame")
b.ReportMetric(1e9/nsPerFrame, "fps_ceiling")
}
// runPipelineStreamBench includes the libghostty-vt emulator.Write
// CGO call and a stdout write to io.Discard alongside the renderer
// — i.e. everything OnPTYOut does in production except the host
// terminal's own paint time (which patterm doesn't control). This
// is the honest "can we hit N fps end-to-end?" measurement.
func runPipelineStreamBench(b *testing.B, frame []byte, fps int) {
frames := fps * 3
totalBytes := int64(len(frame) * frames)
b.SetBytes(totalBytes)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
em, err := vt.NewGhosttyEmulator(80, 40)
if err != nil {
b.Fatalf("emulator: %v", err)
}
vr := newViewportRenderer(newTerminalLayout(120, 40))
for f := 0; f < frames; f++ {
if _, werr := em.Write(frame); werr != nil {
b.Fatalf("emulator.Write: %v", werr)
}
out := vr.Render(frame)
// Match OnPTYOut's autowrap prelude/postlude wrapping so
// the byte count is faithful.
_, _ = io.Discard.Write([]byte("\x1b[?7l"))
_, _ = io.Discard.Write(out)
_, _ = io.Discard.Write([]byte("\x1b[?7h"))
}
_ = em.Close()
}
nsPerFrame := float64(b.Elapsed().Nanoseconds()) / float64(b.N*frames)
b.ReportMetric(nsPerFrame/1000.0, "µs/frame")
b.ReportMetric(1e9/nsPerFrame, "fps_ceiling")
budgetNs := 1e9 / float64(fps)
b.ReportMetric(nsPerFrame/budgetNs*100, "budget_pct")
}
// BenchmarkPipeline_ASCIIVideo_* — the FULL OnPTYOut path
// (emulator.Write CGO + viewport renderer + a stdout write to
// io.Discard) running at 30/60/120 fps targets. These are the
// numbers to trust when asking "can we sustain N fps?" The
// renderer-only Stream benchmarks above isolate one stage and
// understate the real cost.
//
// 120 fps is the explicit baseline: anything under 100% of the
// per-frame budget here means we hit 120 fps with margin to spare.
func BenchmarkPipeline_ASCIIVideo_8Color_30fps(b *testing.B) {
runPipelineStreamBench(b, buildASCIIVideoFrame(80, 40), 30)
}
func BenchmarkPipeline_ASCIIVideo_8Color_60fps(b *testing.B) {
runPipelineStreamBench(b, buildASCIIVideoFrame(80, 40), 60)
}
func BenchmarkPipeline_ASCIIVideo_8Color_120fps(b *testing.B) {
runPipelineStreamBench(b, buildASCIIVideoFrame(80, 40), 120)
}
func BenchmarkPipeline_ASCIIVideo_TrueColor_30fps(b *testing.B) {
runPipelineStreamBench(b, buildASCIIVideoFrameTrueColor(80, 40), 30)
}
func BenchmarkPipeline_ASCIIVideo_TrueColor_60fps(b *testing.B) {
runPipelineStreamBench(b, buildASCIIVideoFrameTrueColor(80, 40), 60)
}
func BenchmarkPipeline_ASCIIVideo_TrueColor_120fps(b *testing.B) {
runPipelineStreamBench(b, buildASCIIVideoFrameTrueColor(80, 40), 120)
}
// BenchmarkSessionResume_5MiBStyled simulates the user's
// motivating case: claude resuming a long chat session and dumping
// the whole history. 5 MiB of styled output as a single Render
// call. Numbers here tell us how long the visible "scrolling
// while resume loads" window will be.
func BenchmarkSessionResume_5MiBStyled(b *testing.B) {
chunk := buildStyledLinesChunk(5 * 1024 * 1024)
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(chunk)
}
}
// BenchmarkSessionResume_5MiBPlain same as above but pure text.
// Lower bound — what we'd hit if the resume content were styling-
// free.
func BenchmarkSessionResume_5MiBPlain(b *testing.B) {
chunk := buildPlainASCIIChunk(5 * 1024 * 1024)
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(chunk)
}
}

View File

@@ -123,6 +123,19 @@ type Child struct {
portsMu sync.Mutex
ports []PortSighting
// Idle-detection state. idleState carries the classifier's current
// opinion (StateIdle / StateWorking / …). lastTitleNS is the wall
// time of the most recent OSC title change — separate from
// lastWriteNS so the osc_title_* strategies can ignore plain output
// churn. idleDetection is the compiled per-preset config, resolved
// once at spawn and immutable thereafter.
idleState atomic.Pointer[IdleState]
idleReason atomic.Pointer[string]
titleMu sync.RWMutex
title string
lastTitleNS atomic.Int64
idleDetection *resolvedIdleDetection
cleanupMu sync.Mutex
cleanupPaths []string
restarting atomic.Bool
@@ -330,6 +343,75 @@ func (c *Child) IdleMS() int64 {
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
// TitleIdleMS returns how many milliseconds since the OSC window title
// last changed. 0 means "no title set yet".
func (c *Child) TitleIdleMS() int64 {
last := c.lastTitleNS.Load()
if last == 0 {
return 0
}
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
// Title returns the most recent OSC 0/2 title.
func (c *Child) Title() string {
c.titleMu.RLock()
defer c.titleMu.RUnlock()
return c.title
}
// recordTitle updates the cached title and bumps lastTitleNS when it
// actually changes. Called from Session.pumpChild after each PTY chunk
// — cheap because most chunks don't carry an OSC sequence.
func (c *Child) recordTitle(newTitle string) {
c.titleMu.Lock()
if c.title == newTitle {
c.titleMu.Unlock()
return
}
c.title = newTitle
c.titleMu.Unlock()
c.lastTitleNS.Store(time.Now().UnixNano())
}
// IdleState returns the classifier's current opinion. Empty string
// (StateUnknown) means the classifier hasn't run yet for this child.
func (c *Child) IdleState() IdleState {
p := c.idleState.Load()
if p == nil {
return StateUnknown
}
return *p
}
// IdleReason returns the human-readable reason the classifier last
// recorded. Empty when no classification has happened yet.
func (c *Child) IdleReason() string {
p := c.idleReason.Load()
if p == nil {
return ""
}
return *p
}
// setIdleState updates idleState + idleReason. Returns true when the
// state actually changed (so callers can fan out a notification).
func (c *Child) setIdleState(s IdleState, reason string) bool {
prev := c.IdleState()
if prev == s {
return false
}
c.idleState.Store(&s)
c.idleReason.Store(&reason)
return true
}
// setIdleDetection installs the resolved per-preset idle-detection
// config. Called once at spawn; not safe to swap at runtime.
func (c *Child) setIdleDetection(r *resolvedIdleDetection) {
c.idleDetection = r
}
func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1)

View File

@@ -0,0 +1,96 @@
package app
import (
"context"
"time"
)
// classifierTickInterval is how often the per-session classifier wakes
// up to re-evaluate every child's state. 250ms is fast enough that
// the sidebar badge looks live, slow enough that the cost is invisible
// even with dozens of children.
const classifierTickInterval = 250 * time.Millisecond
// classifierTailBytes is the size of the ring-buffer tail the
// classifier scans for promoter regexes. Big enough to catch a multi-
// line "Approve?" prompt, small enough that we don't pay for a full
// 1 MiB regex scan every tick.
const classifierTailBytes = 4096
// runClassifier loops over every live child every classifierTickInterval
// and updates IdleState when it changes. It runs until ctx is cancelled
// (the host shutdown path cancels). One goroutine per Session is plenty
// — the work is cheap (atomic loads + ~4 KiB regex scan per child).
func (s *Session) runClassifier(ctx context.Context) {
ticker := time.NewTicker(classifierTickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.classifyAll()
}
}
}
func (s *Session) classifyAll() {
for _, c := range s.Children() {
s.classifyOne(c)
}
}
func (s *Session) classifyOne(c *Child) {
st := c.Status()
exited := st == StatusExited || st == StatusErrored
exitNonZero := false
if exited {
exitNonZero = c.ExitCode() != 0
}
idleMS := c.IdleMS()
titleIdleMS := c.TitleIdleMS()
title := c.Title()
tail := c.tailBytes(classifierTailBytes)
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail)
if c.setIdleState(state, reason) {
s.emitStateChanged(c.ID, state)
}
}
// tailBytes returns up to n bytes from the end of the ring buffer.
// Safe to call from the classifier goroutine while pumpChild writes
// from another goroutine — both serialise on ringMu.
func (c *Child) tailBytes(n int) []byte {
c.ringMu.Lock()
defer c.ringMu.Unlock()
have := int64(ringCap)
if !c.ringFull {
have = c.ringWrites
}
if have == 0 {
return nil
}
want := int64(n)
if want > have {
want = have
}
out := make([]byte, want)
// The ring layout matches StreamRead: when not full, byte k lives
// at index k; when full, the oldest byte sits at ringPos and the
// newest at (ringPos-1) mod ringCap.
if !c.ringFull {
copy(out, c.ring[c.ringWrites-want:c.ringWrites])
return out
}
// Tail starts `want` bytes back from the write head.
start := (c.ringPos - int(want) + ringCap) % ringCap
first := ringCap - start
if first > int(want) {
first = int(want)
}
copy(out, c.ring[start:start+first])
if first < int(want) {
copy(out[first:], c.ring[:int(want)-first])
}
return out
}

155
internal/app/debug.go Normal file
View File

@@ -0,0 +1,155 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// debugCapture implements ChildEventListener and writes structured
// debug artefacts under a single directory:
//
// - patterm.log — the existing logf() stream
// - events.jsonl — one JSON object per lifecycle event
// - <id>.raw — raw PTY bytes for each child, by id+name
//
// The capture is installed only when --debug=<dir> is set, so default
// runs pay nothing.
type debugCapture struct {
dir string
logPath string
mu sync.Mutex
events *os.File
rawByID map[string]*os.File
}
func openDebugCapture(dir string) (*debugCapture, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
logPath := filepath.Join(dir, "patterm.log")
// Truncate-style fresh log per run is friendlier for grep'ing one
// session. The existing logf opens O_APPEND though, so concurrent
// runs against the same dir would interleave — that's on the user.
if f, err := os.Create(logPath); err != nil {
return nil, err
} else {
_ = f.Close()
}
ev, err := os.Create(filepath.Join(dir, "events.jsonl"))
if err != nil {
return nil, err
}
dc := &debugCapture{
dir: dir,
logPath: logPath,
events: ev,
rawByID: make(map[string]*os.File),
}
dc.writeEvent("session_start", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"pid": os.Getpid(),
})
return dc, nil
}
func (d *debugCapture) LogPath() string { return d.logPath }
func (d *debugCapture) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
d.writeEventLocked("session_end", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
})
for _, f := range d.rawByID {
_ = f.Close()
}
d.rawByID = nil
if d.events != nil {
_ = d.events.Close()
d.events = nil
}
return nil
}
func (d *debugCapture) OnChildSpawned(c *Child) {
d.writeEvent("child_spawned", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": c.ID,
"name": c.Name,
"kind": string(c.Kind),
"parent_id": c.ParentID,
"preset": c.PresetRef,
"argv": c.Argv,
})
}
func (d *debugCapture) OnChildExited(c *Child) {
d.writeEvent("child_exited", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": c.ID,
"name": c.Name,
"exit_code": c.ExitCode(),
})
d.mu.Lock()
defer d.mu.Unlock()
if f, ok := d.rawByID[c.ID]; ok {
_ = f.Close()
delete(d.rawByID, c.ID)
}
}
func (d *debugCapture) OnChildStateChanged(id string, state IdleState) {
d.writeEvent("child_state", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": id,
"state": string(state),
})
}
func (d *debugCapture) OnPTYOut(childID string, chunk []byte) {
if len(chunk) == 0 {
return
}
d.mu.Lock()
defer d.mu.Unlock()
f, ok := d.rawByID[childID]
if !ok {
path := filepath.Join(d.dir, childID+".raw")
nf, err := os.Create(path)
if err != nil {
return
}
f = nf
d.rawByID[childID] = nf
}
// Listener contract: don't retain chunk past return. Writing now
// is fine; the slice's backing buffer is reused for the next read
// only after this listener chain completes.
_, _ = f.Write(chunk)
}
func (d *debugCapture) writeEvent(kind string, fields map[string]any) {
d.mu.Lock()
defer d.mu.Unlock()
d.writeEventLocked(kind, fields)
}
func (d *debugCapture) writeEventLocked(kind string, fields map[string]any) {
if d.events == nil {
return
}
if fields == nil {
fields = map[string]any{}
}
fields["event"] = kind
enc, err := json.Marshal(fields)
if err != nil {
return
}
_, _ = fmt.Fprintln(d.events, string(enc))
}

View File

@@ -61,12 +61,11 @@ type toolHost struct {
prompter trustPrompter
scratch scratchpadSink
timersMu sync.Mutex
nextTimer int
timers *timerManager
}
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
return &toolHost{
h := &toolHost{
sess: sess,
pads: pads,
launcher: launcher,
@@ -76,6 +75,28 @@ func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, pres
defaultRow: rows,
startedAt: make(map[string]time.Time),
}
h.timers = newTimerManager(sess)
// Plug the timer manager into the session's state-change fan-out so
// idle-aware timers fire when watched children transition into idle.
// Tests can construct a host with a nil session for sizing checks —
// those never run timers, so the subscribe is skipped.
if sess != nil {
sess.Subscribe(timerListenerAdapter{m: h.timers})
}
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.
type timerListenerAdapter struct{ m *timerManager }
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
func (a timerListenerAdapter) OnChildExited(*Child) {}
func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
a.m.onChildStateChanged(id, st)
}
func (h *toolHost) SetSize(cols, rows uint16) {
@@ -531,6 +552,7 @@ func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
default:
}
}
func (n *chunkNotifier) OnChildStateChanged(string, IdleState) {}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
@@ -725,27 +747,59 @@ func (h *toolHost) RequestHumanAttention(callerID, processID, reason string) err
return nil
}
// TimerWait is the legacy fire-and-forget delay timer. It now wraps
// TimerSet with an empty body — defaultFireFn substitutes the
// "[system] Your timer […] has completed." line so behaviour matches
// the original API. New callers should use timer_set with an explicit
// body.
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
caller := h.sess.FindChild(callerID)
if caller == nil {
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", callerID)
return h.timers.TimerSet(callerID, "", label, seconds)
}
func (h *toolHost) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
id, err := h.timers.TimerSet(owner, args.Body, args.Label, args.Seconds)
if err != nil {
return mcp.TimerHandle{}, err
}
h.timersMu.Lock()
h.nextTimer++
id := fmt.Sprintf("t%d", h.nextTimer)
h.timersMu.Unlock()
if label == "" {
label = id
return mcp.TimerHandle{ID: id}, nil
}
func (h *toolHost) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
return h.timers.TimerFireWhenIdleAny(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
}
func (h *toolHost) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
return h.timers.TimerFireWhenIdleAll(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
}
// resolveTimerOwner picks the owner process for a timer. Explicit
// owner_process_id wins; otherwise the caller's own id is used.
// Top-level MCP clients (no callerID) must provide owner_process_id
// explicitly.
func resolveTimerOwner(callerID, explicit string) string {
if explicit != "" {
return explicit
}
go func() {
time.Sleep(time.Duration(seconds * float64(time.Second)))
if !caller.IsLive() {
return
}
line := fmt.Sprintf("[system] Your timer [%s] has completed.\r", label)
_ = caller.InjectAsOrchestrator([]byte(line))
}()
return id, nil
return callerID
}
func (h *toolHost) TimerCancel(callerID, id string) error {
return h.timers.TimerCancel(callerID, id)
}
func (h *toolHost) TimerPause(callerID, id string) error {
return h.timers.TimerPause(callerID, id)
}
func (h *toolHost) TimerResume(callerID, id string) error {
return h.timers.TimerResume(callerID, id)
}
func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
return h.timers.TimerList(callerID), nil
}
// ───────────────────────────────────────────────────────────────────
@@ -816,6 +870,10 @@ func (h *toolHost) processInfoOf(c *Child) mcp.ProcessInfo {
t := h.trust.IsTrusted(c.PresetRef)
info.Trusted = &t
}
if s := c.IdleState(); s != StateUnknown {
info.IdleState = string(s)
info.IdleReason = c.IdleReason()
}
return info
}
@@ -1026,7 +1084,9 @@ func availableToolsForRole(role mcp.CallerRole) []string {
"list_processes", "get_process_status", "get_project_status",
"get_process_output", "get_process_raw_output", "search_output",
"wait_for_pattern", "get_process_ports",
"send_input", "send_message", "request_human_attention", "timer_wait",
"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",
"whoami", "help",
}
@@ -1051,13 +1111,13 @@ func helpFor(topic string) mcp.HelpResponse {
case "spawning":
return mcp.HelpResponse{
Topic: "spawning",
Content: "spawn_agent launches another vendor LLM CLI as a sub-agent (orchestrator only). spawn_process(kind: command, preset: …) starts a stored command; spawn_process(kind: terminal) opens a shell. Command presets need trust the first time — you'll get needs_trust until the human accepts. Whatever you spawn is yours to clean up — see help('lifecycle').",
Content: "spawn_agent launches another vendor LLM CLI as a sub-agent (orchestrator only). spawn_process(kind: command, preset: …) starts a stored command; spawn_process(kind: terminal) opens a shell. Command presets need trust the first time — you'll get needs_trust until the human accepts. ANTI-PATTERNS: do not shell out to `claude` / `codex` / `opencode` (or any other agent CLI) yourself, and do not pipe JSON-RPC into patterm's Unix socket via perl / nc / socat / curl. Either path bypasses caller-identity and the new agent reads back as a stray top-level tab instead of your child — call spawn_agent through the MCP transport you were initialised on. Whatever you spawn is yours to clean up — see help('lifecycle').",
RelatedTools: []string{"spawn_agent", "spawn_process", "start_process", "restart_process", "close_process"},
}
case "lifecycle":
return mcp.HelpResponse{
Topic: "lifecycle",
Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.",
Topic: "lifecycle",
Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.",
RelatedTools: []string{"close_process", "stop_process", "start_process", "list_processes", "get_process_status"},
}
case "inspection":
@@ -1086,9 +1146,18 @@ func helpFor(topic string) mcp.HelpResponse {
}
case "timers":
return mcp.HelpResponse{
Topic: "timers",
Content: "timer_wait returns a timer_id immediately and injects `[system] Your timer [<label>] has completed.` into your pane when it fires. Use it instead of sleeping in your own process.",
RelatedTools: []string{"timer_wait"},
Topic: "timers",
Content: "Timers fire by injecting your chosen body (or a default `[system] Your timer [] has completed.` line) back into your pane as a fresh user turn. Use them instead of sleeping in your own process. " +
"timer_wait / timer_set schedule a delay timer (timer_set lets you set body+label). " +
"timer_fire_when_idle_any fires when any watched process becomes idle (already-idle watchers are excluded from the baseline). " +
"timer_fire_when_idle_all fires when every watched process is idle; if all are idle at registration the response is already_satisfied with no pending timer. " +
"timer_cancel / timer_pause / timer_resume manage outstanding timers; resume re-checks idle conditions in case a watcher went idle while paused. " +
"timer_list shows your pending and paused timers.",
RelatedTools: []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
},
}
case "readiness":
return mcp.HelpResponse{

View File

@@ -3,6 +3,8 @@ package app
import (
"strings"
"testing"
"github.com/hjbdev/patterm/internal/mcp"
)
// mkChild builds a Child without starting a PTY. Use sparingly — the
@@ -164,6 +166,47 @@ func TestHelpSpawningPointsAtLifecycle(t *testing.T) {
}
}
// TestAvailableToolsAdvertisesAllTimerTools makes sure orchestrators
// and sub-agents discover the full timer surface via whoami — not just
// timer_wait. Otherwise agents using whoami for orientation would never
// learn about timer_set, timer_fire_when_idle_*, timer_pause/resume,
// timer_cancel, and timer_list.
func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) {
want := []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
}
for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} {
tools := availableToolsForRole(role)
for _, w := range want {
if !containsString(tools, w) {
t.Fatalf("role %q missing %q in available tools: %v", role, w, tools)
}
}
}
}
// TestHelpTimersDocumentsAllTools mirrors the whoami check for the
// help("timers") topic — the related-tools list must enumerate every
// timer_* tool so callers reading help can dispatch them.
func TestHelpTimersDocumentsAllTools(t *testing.T) {
resp := helpFor("timers")
if resp.Topic != "timers" {
t.Fatalf("topic: %q", resp.Topic)
}
want := []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
}
for _, w := range want {
if !containsString(resp.RelatedTools, w) {
t.Fatalf("timers help missing %q in related tools: %v", w, resp.RelatedTools)
}
}
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
@@ -172,4 +215,3 @@ func containsString(haystack []string, needle string) bool {
}
return false
}

225
internal/app/idle.go Normal file
View File

@@ -0,0 +1,225 @@
package app
import (
"regexp"
"github.com/hjbdev/patterm/internal/preset"
)
// IdleState is the classifier's opinion about what a child is doing.
// Inspired by Solo's five-state model. ERROR is a terminal state — set
// when a child exits non-zero or matches an error-promoter regex —
// while the other four reflect transient runtime state.
type IdleState string
const (
StateUnknown IdleState = ""
StateIdle IdleState = "idle"
StateWorking IdleState = "working"
StateThinking IdleState = "thinking"
StatePermission IdleState = "permission"
StateError IdleState = "error"
)
// IdleStrategy picks the primary signal used to decide idle vs working.
// Promoter regexes can override this on top.
type IdleStrategy string
const (
StrategyOutputActivity IdleStrategy = "output_activity"
StrategyOSCTitleStability IdleStrategy = "osc_title_stability"
StrategyOSCTitleStatus IdleStrategy = "osc_title_status"
)
// defaultIdleThresholdMS is used when a preset doesn't override it.
const defaultIdleThresholdMS = 2000
// resolvedIdleDetection is the compiled, runtime-ready form of a
// preset.IdleDetection block. Built once at child spawn and held
// read-only by the classifier; regex patterns are compiled here so the
// hot path doesn't pay for it.
type resolvedIdleDetection struct {
strategy IdleStrategy
idleThresholdMS int64
titleStatusMap map[string]IdleState
permissionRegexes []*regexp.Regexp
thinkingRegexes []*regexp.Regexp
errorRegexes []*regexp.Regexp
}
// resolveIdleDetection compiles a preset.IdleDetection (which may be
// nil) into the runtime form. Unknown strategies fall back to
// output_activity. Pattern compile errors are skipped silently — the
// preset loader is responsible for surfacing them as warnings.
func resolveIdleDetection(cfg *preset.IdleDetection) *resolvedIdleDetection {
r := &resolvedIdleDetection{
strategy: StrategyOutputActivity,
idleThresholdMS: defaultIdleThresholdMS,
}
if cfg == nil {
return r
}
switch IdleStrategy(cfg.Strategy) {
case StrategyOSCTitleStability, StrategyOSCTitleStatus, StrategyOutputActivity:
r.strategy = IdleStrategy(cfg.Strategy)
}
if cfg.IdleThresholdMS > 0 {
r.idleThresholdMS = int64(cfg.IdleThresholdMS)
}
if len(cfg.TitleStatusMap) > 0 {
r.titleStatusMap = make(map[string]IdleState, len(cfg.TitleStatusMap))
for k, v := range cfg.TitleStatusMap {
switch IdleState(v) {
case StateIdle, StateWorking, StateThinking, StatePermission, StateError:
r.titleStatusMap[k] = IdleState(v)
}
}
}
r.permissionRegexes = compilePatterns(cfg.PermissionPatterns)
r.thinkingRegexes = compilePatterns(cfg.ThinkingPatterns)
r.errorRegexes = compilePatterns(cfg.ErrorPatterns)
return r
}
func compilePatterns(ps []string) []*regexp.Regexp {
if len(ps) == 0 {
return nil
}
out := make([]*regexp.Regexp, 0, len(ps))
for _, p := range ps {
if p == "" {
continue
}
re, err := regexp.Compile(p)
if err != nil {
continue
}
out = append(out, re)
}
return out
}
// classify computes the IdleState from the inputs the classifier loop
// has already gathered. Pure function so it's easy to unit-test.
//
// Resolution order:
// 1. terminal: process exited non-zero → error (latched)
// 2. error-promoter regex match in recent output → error
// 3. permission-promoter regex match → permission
// 4. thinking-promoter regex match → thinking
// 5. strategy-specific base classification (idle vs working).
//
// inputs:
// - exited: whether the child process has exited
// - exitNonZero: whether the exit was non-zero (only meaningful when exited)
// - idleMS: ms since the last PTY output
// - 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) {
if exited {
if exitNonZero {
return StateError, "process exited non-zero"
}
return StateIdle, "process exited cleanly"
}
if cfg == nil {
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
}
if len(tail) > 0 {
if matchAny(cfg.errorRegexes, tail) {
return StateError, "error regex matched"
}
if matchAny(cfg.permissionRegexes, tail) {
return StatePermission, "permission regex matched"
}
if matchAny(cfg.thinkingRegexes, tail) {
return StateThinking, "thinking regex matched"
}
}
threshold := cfg.idleThresholdMS
switch cfg.strategy {
case StrategyOSCTitleStatus:
// First try the title-status map; if no match, fall back to
// title-stability behaviour so we still produce idle/working.
if s, ok := matchTitleStatus(cfg.titleStatusMap, title); ok {
return s, "title status match"
}
fallthrough
case StrategyOSCTitleStability:
// If we've never seen a title, fall back to output activity so
// we don't latch in idle while the child is clearly running.
if titleIdleMS == 0 {
return baseStateFromIdleMS(idleMS, threshold)
}
return baseStateFromIdleMS(titleIdleMS, threshold)
default: // output_activity
return baseStateFromIdleMS(idleMS, threshold)
}
}
func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) {
// idleMS == 0 means "no writes yet" (per Child.IdleMS) — treat as
// not-idle so we don't classify a freshly-spawned child as idle.
if idleMS == 0 {
return StateWorking, "no activity yet"
}
if idleMS < threshold {
return StateWorking, "recent activity"
}
return StateIdle, "quiet for threshold"
}
func matchAny(res []*regexp.Regexp, tail []byte) bool {
for _, re := range res {
if re.Match(tail) {
return true
}
}
return false
}
func matchTitleStatus(m map[string]IdleState, title string) (IdleState, bool) {
if len(m) == 0 || title == "" {
return StateUnknown, false
}
for k, v := range m {
if k == "" {
continue
}
if containsFold(title, k) {
return v, true
}
}
return StateUnknown, false
}
// containsFold reports whether s contains sub, case-insensitively.
// Cheap implementation suitable for short titles.
func containsFold(s, sub string) bool {
if len(sub) == 0 {
return true
}
if len(sub) > len(s) {
return false
}
ls, lsub := lower(s), lower(sub)
for i := 0; i+len(lsub) <= len(ls); i++ {
if ls[i:i+len(lsub)] == lsub {
return true
}
}
return false
}
func lower(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + 32
}
}
return string(b)
}

112
internal/app/idle_test.go Normal file
View File

@@ -0,0 +1,112 @@
package app
import (
"regexp"
"testing"
)
func mustCompile(t *testing.T, p string) *regexp.Regexp {
t.Helper()
re, err := regexp.Compile(p)
if err != nil {
t.Fatalf("regex %q: %v", p, err)
}
return re
}
func TestClassifyOutputActivity(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
cases := []struct {
name string
idleMS int64
want IdleState
}{
{"fresh-spawn no writes", 0, StateWorking},
{"recent activity", 500, StateWorking},
{"under threshold", 1999, StateWorking},
{"at threshold", 2000, StateIdle},
{"over threshold", 5000, StateIdle},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil)
if got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
})
}
}
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 {
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 {
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 {
t.Fatalf("no title yet, recent output: got %q", got)
}
if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle {
t.Fatalf("no title yet, output idle: got %q", got)
}
}
func TestClassifyTitleStatus(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStatus,
idleThresholdMS: 2000,
titleStatusMap: map[string]IdleState{
"thinking": StateThinking,
"permission": StatePermission,
"error": StateError,
},
}
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking {
t.Fatalf("thinking title: got %q", got)
}
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", 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 {
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`)},
}
// Permission promoter beats idle.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); 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 {
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 {
t.Fatalf("thinking 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 {
t.Fatalf("non-zero exit: got %q", got)
}
if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle {
t.Fatalf("clean exit: got %q", got)
}
}

View File

@@ -127,14 +127,15 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
// Spawn with the chosen identity.
cols, rows := l.size()
c, err := l.sess.Spawn(SpawnSpec{
Kind: KindAgent,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
PresetRef: p.Name,
Identity: identity,
CleanupPaths: cleanupPaths,
Kind: KindAgent,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
PresetRef: p.Name,
Identity: identity,
CleanupPaths: cleanupPaths,
IdleDetection: resolveIdleDetection(p.IdleDetection),
}, cols, rows)
if err != nil {
cleanup()
@@ -171,15 +172,20 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s
env = append(env, k+"="+v)
}
cols, rows := l.size()
return l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: p.ResolvedArgv(),
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: p.WorkingDir,
PresetRef: p.Name,
c, err := l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: p.ResolvedArgv(),
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: p.WorkingDir,
PresetRef: p.Name,
IdleDetection: resolveIdleDetection(p.IdleDetection),
}, cols, rows)
if err != nil {
return nil, err
}
return c, nil
}
// LaunchCommandArgv spawns a freeform-argv command entry. Trust gating

462
internal/app/metrics.go Normal file
View File

@@ -0,0 +1,462 @@
package app
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
)
// metricsTracker collects per-hot-path counters and timings. All
// fields are atomic so callers can record from the per-PTY-chunk path
// without taking a lock. Enabled only when --profile is set.
//
// Sampled rates ("X per second", "p99 latency") are not tracked here
// directly — the snapshotter goroutine writes a row to metrics.jsonl
// every second, and analysis tools compute rates from the deltas.
// Aggregate totals are written to metrics.json on shutdown.
type metricsTracker struct {
startedAt time.Time
// PTY chunk arrival → stdout write pipeline (per OnPTYOut call).
ptyChunks atomic.Int64
ptyBytes atomic.Int64
onPTYOutNs atomic.Int64
onPTYOutMaxNs atomic.Int64
onPTYOutDrops atomic.Int64 // chunks for non-focused children — fast-path returns
stdoutWrites atomic.Int64
stdoutBytes atomic.Int64
stdoutNs atomic.Int64
stdoutMaxNs atomic.Int64
// Viewport renderer (state-machine over child PTY bytes).
renderCalls atomic.Int64
renderNs atomic.Int64
renderMaxNs atomic.Int64
// CGO into libghostty-vt (counted from pumpChild).
emuWriteCalls atomic.Int64
emuWriteNs atomic.Int64
emuWriteMaxNs atomic.Int64
emuTitleCalls atomic.Int64
emuTitleNs atomic.Int64
emuTitleSkips atomic.Int64 // OSC-gate fast path — title poll skipped
// Chrome paint pipeline.
sidebarDraws atomic.Int64
sidebarCacheHits atomic.Int64
sidebarNs atomic.Int64
sidebarMaxNs atomic.Int64
tabbarDraws atomic.Int64
tabbarCacheHits atomic.Int64
tabbarNs atomic.Int64
statusDraws atomic.Int64
statusCacheHits atomic.Int64
statusNs atomic.Int64
// Snapshot replay (focus / spawn / nudge).
snapshotReplays atomic.Int64
snapshotNs atomic.Int64
snapshotMaxNs atomic.Int64
// Chrome ticker — distinguishes useful work from idle wakeups.
tickerFires atomic.Int64
tickerIdleFires atomic.Int64 // nothing dirty when the ticker fired
// Output destination (set when enabled).
rowFile *os.File // metrics.jsonl
dir string
}
// newMetricsTracker creates an enabled tracker writing to <dir>/.
// Returns nil + nil err if dir is empty (feature off). Caller must
// call tracker.run(ctx) in a goroutine and tracker.close() at exit.
func newMetricsTracker(dir string) (*metricsTracker, error) {
if dir == "" {
return nil, nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
row, err := os.Create(filepath.Join(dir, "metrics.jsonl"))
if err != nil {
return nil, err
}
return &metricsTracker{
startedAt: time.Now(),
rowFile: row,
dir: dir,
}, nil
}
// observeMax updates dst to max(dst, v) using a CAS loop. Atomic max
// isn't a hardware primitive on most CPUs; this is the standard idiom.
// Spurious wakeups can race but the result settles at the true max.
func observeMax(dst *atomic.Int64, v int64) {
for {
old := dst.Load()
if v <= old {
return
}
if dst.CompareAndSwap(old, v) {
return
}
}
}
// recordPTYOut is called once at the end of each OnPTYOut invocation.
// `dur` is the full per-chunk wall time (renderer + stdout + chrome
// signals); `bytes` is the chunk's byte count.
func (m *metricsTracker) recordPTYOut(dur time.Duration, bytes int) {
if m == nil {
return
}
m.ptyChunks.Add(1)
m.ptyBytes.Add(int64(bytes))
ns := dur.Nanoseconds()
m.onPTYOutNs.Add(ns)
observeMax(&m.onPTYOutMaxNs, ns)
}
func (m *metricsTracker) recordPTYOutDrop() {
if m == nil {
return
}
m.onPTYOutDrops.Add(1)
}
func (m *metricsTracker) recordRender(dur time.Duration) {
if m == nil {
return
}
m.renderCalls.Add(1)
ns := dur.Nanoseconds()
m.renderNs.Add(ns)
observeMax(&m.renderMaxNs, ns)
}
func (m *metricsTracker) recordStdout(dur time.Duration, bytes int) {
if m == nil {
return
}
m.stdoutWrites.Add(1)
m.stdoutBytes.Add(int64(bytes))
ns := dur.Nanoseconds()
m.stdoutNs.Add(ns)
observeMax(&m.stdoutMaxNs, ns)
}
func (m *metricsTracker) recordEmuWrite(dur time.Duration) {
if m == nil {
return
}
m.emuWriteCalls.Add(1)
ns := dur.Nanoseconds()
m.emuWriteNs.Add(ns)
observeMax(&m.emuWriteMaxNs, ns)
}
func (m *metricsTracker) recordEmuTitle(dur time.Duration, skipped bool) {
if m == nil {
return
}
if skipped {
m.emuTitleSkips.Add(1)
return
}
m.emuTitleCalls.Add(1)
m.emuTitleNs.Add(dur.Nanoseconds())
}
func (m *metricsTracker) recordSidebar(dur time.Duration, cacheHit bool) {
if m == nil {
return
}
m.sidebarDraws.Add(1)
if cacheHit {
m.sidebarCacheHits.Add(1)
}
ns := dur.Nanoseconds()
m.sidebarNs.Add(ns)
observeMax(&m.sidebarMaxNs, ns)
}
func (m *metricsTracker) recordTabbar(dur time.Duration, cacheHit bool) {
if m == nil {
return
}
m.tabbarDraws.Add(1)
if cacheHit {
m.tabbarCacheHits.Add(1)
}
m.tabbarNs.Add(dur.Nanoseconds())
}
func (m *metricsTracker) recordStatus(dur time.Duration, cacheHit bool) {
if m == nil {
return
}
m.statusDraws.Add(1)
if cacheHit {
m.statusCacheHits.Add(1)
}
m.statusNs.Add(dur.Nanoseconds())
}
func (m *metricsTracker) recordSnapshot(dur time.Duration) {
if m == nil {
return
}
m.snapshotReplays.Add(1)
ns := dur.Nanoseconds()
m.snapshotNs.Add(ns)
observeMax(&m.snapshotMaxNs, ns)
}
func (m *metricsTracker) recordTickerFire(didWork bool) {
if m == nil {
return
}
m.tickerFires.Add(1)
if !didWork {
m.tickerIdleFires.Add(1)
}
}
// snapshot captures the tracker's current state as a JSON-serialisable
// map. Suitable for both the per-second JSONL row and the final
// metrics.json aggregate.
type metricsSnapshot struct {
WallSeconds float64 `json:"wall_seconds"`
PTYChunks int64 `json:"pty_chunks"`
PTYBytes int64 `json:"pty_bytes"`
OnPTYOutNs int64 `json:"on_pty_out_ns_total"`
OnPTYOutMaxNs int64 `json:"on_pty_out_ns_max"`
OnPTYOutDrops int64 `json:"on_pty_out_drops"`
StdoutWrites int64 `json:"stdout_writes"`
StdoutBytes int64 `json:"stdout_bytes"`
StdoutNs int64 `json:"stdout_ns_total"`
StdoutMaxNs int64 `json:"stdout_ns_max"`
RenderCalls int64 `json:"render_calls"`
RenderNs int64 `json:"render_ns_total"`
RenderMaxNs int64 `json:"render_ns_max"`
EmuWriteCalls int64 `json:"emu_write_calls"`
EmuWriteNs int64 `json:"emu_write_ns_total"`
EmuWriteMaxNs int64 `json:"emu_write_ns_max"`
EmuTitleCalls int64 `json:"emu_title_calls"`
EmuTitleNs int64 `json:"emu_title_ns_total"`
EmuTitleSkips int64 `json:"emu_title_skips"`
SidebarDraws int64 `json:"sidebar_draws"`
SidebarCacheHits int64 `json:"sidebar_cache_hits"`
SidebarNs int64 `json:"sidebar_ns_total"`
SidebarMaxNs int64 `json:"sidebar_ns_max"`
TabbarDraws int64 `json:"tabbar_draws"`
TabbarCacheHits int64 `json:"tabbar_cache_hits"`
TabbarNs int64 `json:"tabbar_ns_total"`
StatusDraws int64 `json:"status_draws"`
StatusCacheHits int64 `json:"status_cache_hits"`
StatusNs int64 `json:"status_ns_total"`
SnapshotReplays int64 `json:"snapshot_replays"`
SnapshotNs int64 `json:"snapshot_ns_total"`
SnapshotMaxNs int64 `json:"snapshot_ns_max"`
TickerFires int64 `json:"ticker_fires"`
TickerIdleFires int64 `json:"ticker_idle_fires"`
// Derived rates (computed at snapshot time so consumers don't have
// to). All "per_second" values are averaged over wall_seconds.
PTYChunksPerSec float64 `json:"pty_chunks_per_sec"`
PTYBytesPerSec float64 `json:"pty_bytes_per_sec"`
OnPTYOutMeanUs float64 `json:"on_pty_out_mean_us"`
StdoutMeanUs float64 `json:"stdout_mean_us"`
EmuWriteMeanUs float64 `json:"emu_write_mean_us"`
SidebarMeanUs float64 `json:"sidebar_mean_us"`
SidebarCacheHitRate float64 `json:"sidebar_cache_hit_rate"`
TabbarCacheHitRate float64 `json:"tabbar_cache_hit_rate"`
StatusCacheHitRate float64 `json:"status_cache_hit_rate"`
EmuTitleSkipRate float64 `json:"emu_title_skip_rate"`
TickerIdleRate float64 `json:"ticker_idle_rate"`
Timestamp string `json:"timestamp"`
}
func (m *metricsTracker) snapshotNow() metricsSnapshot {
wall := time.Since(m.startedAt).Seconds()
if wall <= 0 {
wall = 1
}
chunks := m.ptyChunks.Load()
bytes := m.ptyBytes.Load()
onptyTotal := m.onPTYOutNs.Load()
stdW := m.stdoutWrites.Load()
stdNs := m.stdoutNs.Load()
emuW := m.emuWriteCalls.Load()
emuWNs := m.emuWriteNs.Load()
sbDraws := m.sidebarDraws.Load()
sbHits := m.sidebarCacheHits.Load()
sbNs := m.sidebarNs.Load()
tbDraws := m.tabbarDraws.Load()
tbHits := m.tabbarCacheHits.Load()
stDraws := m.statusDraws.Load()
stHits := m.statusCacheHits.Load()
emuTC := m.emuTitleCalls.Load()
emuTS := m.emuTitleSkips.Load()
tickerF := m.tickerFires.Load()
tickerI := m.tickerIdleFires.Load()
div := func(num, denom int64) float64 {
if denom == 0 {
return 0
}
return float64(num) / float64(denom)
}
return metricsSnapshot{
WallSeconds: wall,
PTYChunks: chunks,
PTYBytes: bytes,
OnPTYOutNs: onptyTotal,
OnPTYOutMaxNs: m.onPTYOutMaxNs.Load(),
OnPTYOutDrops: m.onPTYOutDrops.Load(),
StdoutWrites: stdW,
StdoutBytes: m.stdoutBytes.Load(),
StdoutNs: stdNs,
StdoutMaxNs: m.stdoutMaxNs.Load(),
RenderCalls: m.renderCalls.Load(),
RenderNs: m.renderNs.Load(),
RenderMaxNs: m.renderMaxNs.Load(),
EmuWriteCalls: emuW,
EmuWriteNs: emuWNs,
EmuWriteMaxNs: m.emuWriteMaxNs.Load(),
EmuTitleCalls: emuTC,
EmuTitleNs: m.emuTitleNs.Load(),
EmuTitleSkips: emuTS,
SidebarDraws: sbDraws,
SidebarCacheHits: sbHits,
SidebarNs: sbNs,
SidebarMaxNs: m.sidebarMaxNs.Load(),
TabbarDraws: tbDraws,
TabbarCacheHits: tbHits,
TabbarNs: m.tabbarNs.Load(),
StatusDraws: stDraws,
StatusCacheHits: stHits,
StatusNs: m.statusNs.Load(),
SnapshotReplays: m.snapshotReplays.Load(),
SnapshotNs: m.snapshotNs.Load(),
SnapshotMaxNs: m.snapshotMaxNs.Load(),
TickerFires: tickerF,
TickerIdleFires: tickerI,
PTYChunksPerSec: float64(chunks) / wall,
PTYBytesPerSec: float64(bytes) / wall,
OnPTYOutMeanUs: div(onptyTotal/1000, chunks),
StdoutMeanUs: div(stdNs/1000, stdW),
EmuWriteMeanUs: div(emuWNs/1000, emuW),
SidebarMeanUs: div(sbNs/1000, sbDraws),
SidebarCacheHitRate: div(sbHits, sbDraws),
TabbarCacheHitRate: div(tbHits, tbDraws),
StatusCacheHitRate: div(stHits, stDraws),
EmuTitleSkipRate: div(emuTS, emuTC+emuTS),
TickerIdleRate: div(tickerI, tickerF),
Timestamp: time.Now().Format(time.RFC3339Nano),
}
}
// run is the snapshotter goroutine: write a JSONL row every second
// until ctx is cancelled. Stops cleanly without flushing partial
// rows.
func (m *metricsTracker) run(ctx context.Context) {
if m == nil {
return
}
enc := json.NewEncoder(m.rowFile)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
snap := m.snapshotNow()
_ = enc.Encode(snap)
}
}
}
// close writes the final aggregate snapshot to metrics.json + a
// short human-readable summary.txt, then closes the row file. Safe
// to call on a nil receiver.
func (m *metricsTracker) close() {
if m == nil {
return
}
snap := m.snapshotNow()
if f, err := os.Create(filepath.Join(m.dir, "metrics.json")); err == nil {
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
_ = enc.Encode(snap)
_ = f.Close()
}
if f, err := os.Create(filepath.Join(m.dir, "summary.txt")); err == nil {
writeSummary(f, snap)
_ = f.Close()
}
if m.rowFile != nil {
_ = m.rowFile.Close()
m.rowFile = nil
}
}
// writeSummary renders a brief human-readable digest of a snapshot.
// Designed for `cat summary.txt` after a session — quick orientation
// before diving into metrics.json / pprof.
func writeSummary(w *os.File, s metricsSnapshot) {
fmt.Fprintf(w, "patterm performance summary\n")
fmt.Fprintf(w, "===========================\n\n")
fmt.Fprintf(w, "session length: %.1fs\n", s.WallSeconds)
fmt.Fprintf(w, "pty chunks: %d (%.1f /s)\n", s.PTYChunks, s.PTYChunksPerSec)
fmt.Fprintf(w, "pty bytes: %d (%.0f /s, %.1f KiB/s)\n",
s.PTYBytes, s.PTYBytesPerSec, s.PTYBytesPerSec/1024)
fmt.Fprintf(w, "pty chunks dropped: %d (focus not on caller — fast-path return)\n", s.OnPTYOutDrops)
fmt.Fprintf(w, "\n")
fmt.Fprintf(w, "OnPTYOut mean: %.1fµs max: %.1fms\n",
s.OnPTYOutMeanUs, float64(s.OnPTYOutMaxNs)/1e6)
fmt.Fprintf(w, "viewport.Render calls: %d total %.1fms max %.1fms\n",
s.RenderCalls, float64(s.RenderNs)/1e6, float64(s.RenderMaxNs)/1e6)
fmt.Fprintf(w, "stdout writes: %d mean %.1fµs max %.1fms bytes %d\n",
s.StdoutWrites, s.StdoutMeanUs, float64(s.StdoutMaxNs)/1e6, s.StdoutBytes)
fmt.Fprintf(w, "\n")
fmt.Fprintf(w, "emulator.Write (cgo): %d mean %.1fµs max %.1fms\n",
s.EmuWriteCalls, s.EmuWriteMeanUs, float64(s.EmuWriteMaxNs)/1e6)
fmt.Fprintf(w, "emulator.Title polls: %d real, %d gated skip rate %.1f%%\n",
s.EmuTitleCalls, s.EmuTitleSkips, s.EmuTitleSkipRate*100)
fmt.Fprintf(w, "\n")
fmt.Fprintf(w, "sidebar draws: %d mean %.1fµs max %.1fms cache-hit %.1f%%\n",
s.SidebarDraws, s.SidebarMeanUs, float64(s.SidebarMaxNs)/1e6, s.SidebarCacheHitRate*100)
fmt.Fprintf(w, "tabbar draws: %d cache-hit %.1f%%\n",
s.TabbarDraws, s.TabbarCacheHitRate*100)
fmt.Fprintf(w, "status draws: %d cache-hit %.1f%%\n",
s.StatusDraws, s.StatusCacheHitRate*100)
fmt.Fprintf(w, "snapshot replays: %d total %.1fms max %.1fms\n",
s.SnapshotReplays, float64(s.SnapshotNs)/1e6, float64(s.SnapshotMaxNs)/1e6)
fmt.Fprintf(w, "\n")
fmt.Fprintf(w, "chrome ticker: %d fires, %d idle idle rate %.1f%%\n",
s.TickerFires, s.TickerIdleFires, s.TickerIdleRate*100)
}

View File

@@ -0,0 +1,116 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestMetricsTrackerDisabledByEmptyDir(t *testing.T) {
m, err := newMetricsTracker("")
if err != nil {
t.Fatalf("newMetricsTracker(\"\") err: %v", err)
}
if m != nil {
t.Fatalf("expected nil tracker for empty dir, got %v", m)
}
}
func TestMetricsTrackerRecordsAndWrites(t *testing.T) {
dir := t.TempDir()
m, err := newMetricsTracker(dir)
if err != nil {
t.Fatalf("newMetricsTracker: %v", err)
}
if m == nil {
t.Fatal("expected non-nil tracker")
}
m.recordPTYOut(2*time.Millisecond, 1024)
m.recordPTYOut(5*time.Millisecond, 4096)
m.recordRender(800 * time.Microsecond)
m.recordStdout(300*time.Microsecond, 1100)
m.recordEmuWrite(150 * time.Microsecond)
m.recordEmuTitle(0, true)
m.recordEmuTitle(20*time.Microsecond, false)
m.recordSidebar(100*time.Microsecond, true)
m.recordSidebar(900*time.Microsecond, false)
m.recordTabbar(50*time.Microsecond, true)
m.recordStatus(40*time.Microsecond, true)
m.recordSnapshot(2 * time.Millisecond)
m.recordTickerFire(false)
m.recordTickerFire(true)
m.recordPTYOutDrop()
m.close()
// metrics.json should exist and parse, and reflect what we recorded.
raw, err := os.ReadFile(filepath.Join(dir, "metrics.json"))
if err != nil {
t.Fatalf("read metrics.json: %v", err)
}
var snap metricsSnapshot
if err := json.Unmarshal(raw, &snap); err != nil {
t.Fatalf("parse metrics.json: %v", err)
}
if snap.PTYChunks != 2 {
t.Errorf("PTYChunks = %d, want 2", snap.PTYChunks)
}
if snap.PTYBytes != 5120 {
t.Errorf("PTYBytes = %d, want 5120", snap.PTYBytes)
}
if snap.OnPTYOutMaxNs != (5 * time.Millisecond).Nanoseconds() {
t.Errorf("OnPTYOutMaxNs = %d, want %d",
snap.OnPTYOutMaxNs, (5 * time.Millisecond).Nanoseconds())
}
if snap.SidebarDraws != 2 {
t.Errorf("SidebarDraws = %d, want 2", snap.SidebarDraws)
}
if snap.SidebarCacheHits != 1 {
t.Errorf("SidebarCacheHits = %d, want 1", snap.SidebarCacheHits)
}
if snap.SidebarCacheHitRate != 0.5 {
t.Errorf("SidebarCacheHitRate = %v, want 0.5", snap.SidebarCacheHitRate)
}
if snap.EmuTitleCalls != 1 || snap.EmuTitleSkips != 1 {
t.Errorf("emu title accounting: calls=%d skips=%d, want 1/1",
snap.EmuTitleCalls, snap.EmuTitleSkips)
}
if snap.TickerFires != 2 || snap.TickerIdleFires != 1 {
t.Errorf("ticker accounting: fires=%d idle=%d, want 2/1",
snap.TickerFires, snap.TickerIdleFires)
}
if snap.OnPTYOutDrops != 1 {
t.Errorf("OnPTYOutDrops = %d, want 1", snap.OnPTYOutDrops)
}
// summary.txt should also be present and non-empty.
info, err := os.Stat(filepath.Join(dir, "summary.txt"))
if err != nil {
t.Fatalf("stat summary.txt: %v", err)
}
if info.Size() == 0 {
t.Fatal("summary.txt is empty")
}
}
func TestMetricsTrackerNilSafe(t *testing.T) {
// Every record* method must be safe to call on a nil receiver
// because the hot paths use that to avoid an enabled-check.
var m *metricsTracker
m.recordPTYOut(time.Millisecond, 100)
m.recordPTYOutDrop()
m.recordRender(time.Microsecond)
m.recordStdout(time.Microsecond, 50)
m.recordEmuWrite(time.Microsecond)
m.recordEmuTitle(time.Microsecond, false)
m.recordEmuTitle(0, true)
m.recordSidebar(time.Microsecond, true)
m.recordTabbar(time.Microsecond, false)
m.recordStatus(time.Microsecond, true)
m.recordSnapshot(time.Microsecond)
m.recordTickerFire(true)
m.close()
}

View File

@@ -11,12 +11,13 @@ import (
// paletteAction is what the palette returns when the user picks an item.
type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "spawn-process-form" |
// "spawn-process-submit" | "switch" | "kill" | "quit" |
// "cancel" | "pad-delete" | "pad-rename" | "pad-rename-form" |
// "pad-rename-submit" | "pad-edit" | "agent-rename" |
// "agent-rename-form" | "agent-rename-submit" | "agent-close" |
// "proc-rename" | "proc-rename-form" | "proc-rename-submit" |
// "proc-delete" | "proc-stop" | "proc-restart"
// "spawn-process-submit" | "spawn-terminal" | "switch" |
// "kill" | "quit" | "cancel" | "pad-delete" | "pad-rename" |
// "pad-rename-form" | "pad-rename-submit" | "pad-edit" |
// "agent-rename" | "agent-rename-form" | "agent-rename-submit" |
// "agent-close" | "proc-rename" | "proc-rename-form" |
// "proc-rename-submit" | "proc-delete" | "proc-stop" |
// "proc-restart"
kind string
// For spawn-agent / spawn-process, the preset to launch.
@@ -276,6 +277,16 @@ func (p *paletteState) allItems() []paletteItem {
})
}
// "New Terminal" — bare interactive $SHELL pane. Distinct from
// "Run process: …" presets in that it spawns a KindTerminal (which
// disappears from the sidebar on exit rather than sticking around
// for restart). One quick keystroke; no form.
out = append(out, paletteItem{
label: "New Terminal",
hint: "bare interactive $SHELL · removed on exit",
action: paletteAction{kind: "spawn-terminal"},
})
// Freeform "Spawn process…" entry. Opens a sub-form for typing an
// arbitrary command line and ticking "relaunch on exit". The action
// kind is intercepted by acceptOrEnterForm so accept switches the
@@ -288,14 +299,14 @@ func (p *paletteState) allItems() []paletteItem {
action: paletteAction{kind: "spawn-process-form"},
})
// Kill entries last among the action rows, before Quit. Mirror the
// Close entries last among the action rows, before Quit. Mirror the
// "(current)" marker from switch entries so the focused tab is
// obvious when scanning the kill list.
// obvious when scanning the close list.
for _, c := range p.children {
if c.Status() != StatusRunning {
continue
}
label := "Kill " + c.DisplayName()
label := "Close " + c.DisplayName()
if c.ID == p.focused {
label = "• " + label + " (current)"
}

View File

@@ -50,6 +50,11 @@ type Session struct {
// JSON file so they can be re-spawned after patterm restarts.
// Optional; nil means "no persistence" (used by unit tests).
persistStore *persist.Store
// metrics is the optional performance tracker. nil when --profile
// is off. The pump goroutine reads it via atomic Load so installing
// metrics post-construction doesn't race with running children.
metrics atomic.Pointer[metricsTracker]
}
// SetPersistStore attaches a process-persistence store. Future Spawn /
@@ -61,6 +66,18 @@ func (s *Session) SetPersistStore(p *persist.Store) {
s.mu.Unlock()
}
// SetMetrics installs the per-session performance tracker. Safe to
// call with nil to disable (the default). Reads on the hot path go
// through atomic.Pointer.Load() with no lock; SetMetrics swaps the
// pointer once at startup.
func (s *Session) SetMetrics(m *metricsTracker) {
s.metrics.Store(m)
}
func (s *Session) loadMetrics() *metricsTracker {
return s.metrics.Load()
}
// ChildEventListener is implemented by the TUI to react to lifecycle
// events without polling.
type ChildEventListener interface {
@@ -70,6 +87,10 @@ type ChildEventListener interface {
// Only the focused-child chunk should reach the screen — the TUI
// filters by id.
OnPTYOut(childID string, chunk []byte)
// OnChildStateChanged fires when the idle-detection classifier
// updates a child's IdleState. Listeners use this to repaint the
// sidebar badge and to evaluate idle-aware timers.
OnChildStateChanged(childID string, state IdleState)
}
func NewSession(projectDir, projectKey string) *Session {
@@ -140,6 +161,12 @@ func (s *Session) emitPTYOut(id string, chunk []byte) {
}
}
func (s *Session) emitStateChanged(id string, state IdleState) {
for _, l := range s.listenersSnapshot() {
l.OnChildStateChanged(id, state)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
@@ -168,6 +195,11 @@ type SpawnSpec struct {
// or is closed. They must be attached before the PTY starts so a
// fast-exiting child cannot outrun cleanup registration.
CleanupPaths []string
// IdleDetection is the resolved per-preset idle classifier config.
// Must be installed before the child is published to s.children so
// the classifier goroutine never observes a nil/default config for
// a preset that overrides it.
IdleDetection *resolvedIdleDetection
}
// Spawn creates a new entry and starts its PTY. For Kind = command the
@@ -198,6 +230,12 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
for _, path := range spec.CleanupPaths {
c.AddCleanupPath(path)
}
// Install idle-detection BEFORE publishing to s.children — otherwise
// the classifier goroutine could read c.idleDetection while the
// launcher is still racing to set it.
if spec.IdleDetection != nil {
c.setIdleDetection(spec.IdleDetection)
}
runID, err := c.startPTY(cols, rows)
if err != nil {
c.cleanupOwnedPaths()
@@ -371,9 +409,38 @@ func (s *Session) pumpChild(c *Child, runID uint64) {
}
chunk := buf[:n]
if em := c.Emulator(); em != nil {
m := s.loadMetrics()
wstart := time.Time{}
if m != nil {
wstart = time.Now()
}
if _, werr := em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr)
}
if m != nil {
m.recordEmuWrite(time.Since(wstart))
}
// OSC 0/2 title updates ride on the same byte stream as
// the rest of the output. Polling the emulator after each
// chunk is cheap on its own (one CGO call) but codex/
// ratatui sends so many small chunks that the per-chunk
// CGO cost becomes measurable. Skip the Title poll when
// the chunk doesn't carry an OSC start byte at all; the
// title can only change on chunks that include one.
if containsOSC(chunk) {
tstart := time.Time{}
if m != nil {
tstart = time.Now()
}
if t, terr := em.Title(); terr == nil {
c.recordTitle(t)
}
if m != nil {
m.recordEmuTitle(time.Since(tstart), false)
}
} else if m != nil {
m.recordEmuTitle(0, true)
}
}
c.recordWrite(chunk)
s.emitPTYOut(c.ID, chunk)
@@ -403,6 +470,23 @@ func (s *Session) reapChild(c *Child, runID uint64) {
if !c.restarting.Load() {
c.cleanupOwnedPaths()
}
// Terminals are ephemeral: unlike command entries (kept around for
// restart_process) and agents (which the user clears via close_process
// once they're done with the corpse), an exited terminal has nothing
// useful left to do. Drop it from the session so it disappears from
// the Processes sidebar / switch list immediately.
if c.Kind == KindTerminal && !c.restarting.Load() {
c.teardownPTY()
s.mu.Lock()
delete(s.children, c.ID)
for i, oid := range s.order {
if oid == c.ID {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
s.mu.Unlock()
}
}
// killDescendantsOf terminates every still-live direct child of
@@ -632,6 +716,24 @@ func (s *Session) Shutdown() {
}
}
// containsOSC reports whether chunk holds a sequence that could begin
// an OSC. OSC starts as ESC ] (0x1b 0x5d) or the bare C1 ] (0x9d),
// so a chunk without either cannot have changed the emulator's OSC
// title state. Used to short-circuit the per-chunk Title() poll from
// pumpChild, which otherwise pays a CGO call for every chunk even
// when codex/ratatui is just emitting SGR-styled output.
func containsOSC(chunk []byte) bool {
for i, b := range chunk {
if b == 0x9d {
return true
}
if b == 0x1b && i+1 < len(chunk) && chunk[i+1] == ']' {
return true
}
}
return false
}
func logf(format string, args ...any) {
if os.Getenv("PATTERM_DEBUG_LOG") == "" {
return

View File

@@ -57,6 +57,50 @@ func TestParentExitKillsDescendants(t *testing.T) {
waitUntilNotLive(t, grandchild)
}
// TestSpawnInstallsIdleDetectionBeforePublish guarantees that a child
// spawned with SpawnSpec.IdleDetection has its resolved config visible
// the instant the child appears in s.children — closing the race where
// the classifier could read c.idleDetection before the launcher set it.
func TestSpawnInstallsIdleDetectionBeforePublish(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
want := &resolvedIdleDetection{
strategy: StrategyOSCTitleStability,
idleThresholdMS: 9999,
}
c, err := sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "sleep 30"},
IdleDetection: want,
}, 80, 24)
if err != nil {
t.Fatalf("spawn: %v", err)
}
defer func() { _ = c.signal(syscall.SIGTERM) }()
// Read back via the same access path the classifier uses
// (sess.Children) so the test fails if the field is set only
// AFTER the child is published.
var found *Child
for _, ch := range sess.Children() {
if ch.ID == c.ID {
found = ch
break
}
}
if found == nil {
t.Fatalf("spawned child %s not in Children()", c.ID)
}
if found.idleDetection == nil {
t.Fatalf("idleDetection nil after Spawn returned")
}
if found.idleDetection.strategy != StrategyOSCTitleStability {
t.Fatalf("strategy: got %q want %q", found.idleDetection.strategy, StrategyOSCTitleStability)
}
if found.idleDetection.idleThresholdMS != 9999 {
t.Fatalf("threshold: got %d want 9999", found.idleDetection.idleThresholdMS)
}
}
func waitUntilLive(t *testing.T, c *Child) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"
)
const (
@@ -11,6 +12,24 @@ const (
statusRows = 1
)
// formatShortDuration renders a duration as a short, sidebar-friendly
// suffix: ms under 1s, "12s" under 60s, "3m" otherwise.
func formatShortDuration(d time.Duration) string {
if d <= 0 {
return "0s"
}
if d < time.Second {
return fmt.Sprintf("%dms", int(d/time.Millisecond))
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d/time.Second))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d/time.Minute))
}
return fmt.Sprintf("%dh", int(d/time.Hour))
}
// drawSidebar paints the right-rail session tree + scratchpad list.
// SPEC §4: the rail is the active session's child hierarchy on top and
// the scratchpad list (with preview) on the bottom.
@@ -19,6 +38,10 @@ const (
// computed main viewport, so the sidebar region is outside the child's
// cursor range. We can redraw freely without fighting the child for cells.
func (st *uiState) drawSidebar() {
var entry time.Time
if st.metrics != nil {
entry = time.Now()
}
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
@@ -62,14 +85,56 @@ func (st *uiState) drawSidebar() {
write(" " + styleActive + text + styleReset)
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
}
// timerIndicator returns a short " ⏱ 12s" or " ⏸ paused" suffix
// when c has a pending or paused timer attached (owns or watches).
// Empty string when no timer is in play.
timerIndicator := func(c *Child) string {
if st.timers == nil {
return ""
}
info := st.timers.activeForChild(c.ID)
if info == nil {
return ""
}
if info.Status == timerStatusPaused {
return " " + styleDim + "⏸" + styleReset
}
remaining := ""
if info.FiresAtUnixMS > 0 {
d := time.Until(time.UnixMilli(info.FiresAtUnixMS))
if d < 0 {
d = 0
}
remaining = formatShortDuration(d)
}
return " " + styleDim + "⏱" + styleReset + " " + styleHint + remaining + styleReset
}
statusGlyph := func(c *Child, focused bool) string {
if c.Status() != StatusRunning {
return styleDim + "○" + styleReset
}
// Idle-detection states paint over the plain running glyph so
// the rail communicates "running but waiting on you" vs "running
// and busy" at a glance. Focused entries always use the accent
// colour so the user's selection stays visible.
style := styleHint
if focused {
return styleAccent + "●" + styleReset
style = styleAccent
}
switch c.IdleState() {
case StateError:
return styleError + "✕" + styleReset
case StatePermission:
return styleAccent + "?" + styleReset
case StateThinking:
return style + "◐" + styleReset
case StateIdle:
return style + "○" + styleReset
case StateWorking:
return style + "●" + styleReset
default:
return style + "●" + styleReset
}
return styleHint + "●" + styleReset
}
// Processes section — top-level command/terminal processes,
@@ -92,9 +157,9 @@ func (st *uiState) drawSidebar() {
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " +
styleBold + c.DisplayName() + styleReset + marker
styleBold + c.DisplayName() + styleReset + marker + timerIndicator(c)
} else {
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker + timerIndicator(c)
}
write(line)
}
@@ -124,9 +189,9 @@ func (st *uiState) drawSidebar() {
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " +
styleBold + c.DisplayName() + styleReset
styleBold + c.DisplayName() + styleReset + timerIndicator(c)
} else {
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset + timerIndicator(c)
}
write(line)
}
@@ -170,10 +235,16 @@ func (st *uiState) drawSidebar() {
st.chromeCacheMu.Lock()
if frame == st.sidebarCache {
st.chromeCacheMu.Unlock()
if st.metrics != nil {
st.metrics.recordSidebar(time.Since(entry), true)
}
return
}
st.sidebarCache = frame
st.chromeCacheMu.Unlock()
if st.metrics != nil {
defer func() { st.metrics.recordSidebar(time.Since(entry), false) }()
}
st.outMu.Lock()
// Save cursor; emit the sidebar; restore.

View File

@@ -11,4 +11,5 @@ const (
styleAccent = "\x1b[38;5;75m"
styleHint = "\x1b[38;5;244m"
styleActive = "\x1b[1;38;5;253m"
styleError = "\x1b[38;5;203m"
)

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"
"unicode/utf8"
)
@@ -17,6 +18,10 @@ const tabBarRows = 2
// to the leftmost tabs so the strip fills the screen edge-to-edge.
// A trailing "+ new" hint sits in the rightmost reserved slot.
func (st *uiState) drawTabBar() {
var entry time.Time
if st.metrics != nil {
entry = time.Now()
}
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
@@ -188,10 +193,16 @@ func (st *uiState) drawTabBar() {
st.chromeCacheMu.Lock()
if frame == st.tabBarCache {
st.chromeCacheMu.Unlock()
if st.metrics != nil {
st.metrics.recordTabbar(time.Since(entry), true)
}
return
}
st.tabBarCache = frame
st.chromeCacheMu.Unlock()
if st.metrics != nil {
defer func() { st.metrics.recordTabbar(time.Since(entry), false) }()
}
st.outMu.Lock()
defer st.outMu.Unlock()

542
internal/app/timers.go Normal file
View File

@@ -0,0 +1,542 @@
package app
import (
"fmt"
"sync"
"time"
"github.com/hjbdev/patterm/internal/mcp"
)
// pendingTimerKind picks the firing rule.
type pendingTimerKind string
const (
timerKindDelay pendingTimerKind = "delay"
timerKindIdleAny pendingTimerKind = "idle_any"
timerKindIdleAll pendingTimerKind = "idle_all"
)
const (
timerStatusPending = "pending"
timerStatusPaused = "paused"
timerStatusFired = "fired"
timerStatusCanceled = "canceled"
)
// pendingTimer is one live timer tracked by the manager. The body is
// delivered verbatim to the owning child's PTY as a fresh user turn
// when the timer fires.
//
// Locking: every field is protected by timerManager.mu. The runtime
// time.Timer (rt) is started outside the lock so the firing goroutine
// can take the lock without deadlocking.
type pendingTimer struct {
id string
label string
body string
ownerID string
kind pendingTimerKind
status string
watched []string
idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction)
firesAt time.Time
pausedRemaining time.Duration
pausedWasMaxWait bool // for idle_*: true if the active timer was max-wait, not delay
rt *time.Timer // delay timer or idle_* max-wait fallback
}
// timerManager owns the pending-timer registry. Mutating operations
// (set, cancel, pause, resume) all serialise through mu; fire callbacks
// from the runtime timer also take mu to safely transition state.
type timerManager struct {
sess *Session
mu sync.Mutex
nextID int
timers map[string]*pendingTimer
// fireFn is the callback used to deliver the body to the owning
// process. Decoupled so tests can substitute a recorder. Defaults
// to caller.InjectAsOrchestrator + "\r".
fireFn func(owner *Child, body, label string)
}
func newTimerManager(sess *Session) *timerManager {
m := &timerManager{
sess: sess,
timers: make(map[string]*pendingTimer),
}
m.fireFn = defaultFireFn
return m
}
func defaultFireFn(owner *Child, body, label string) {
if owner == nil || !owner.IsLive() {
return
}
// Solo delivers body verbatim. patterm's PTY-injection path expects
// a trailing CR so the line submits in TUI agents (Claude/Codex/
// OpenCode all paste-detect). A bare body without CR sits in the
// input buffer; that's almost never what the caller wants.
if body == "" {
body = fmt.Sprintf("[system] Your timer [%s] has completed.", label)
}
_ = owner.InjectAsOrchestrator([]byte(body + "\r"))
}
func (m *timerManager) mintID() string {
m.nextID++
return fmt.Sprintf("t%d", m.nextID)
}
// TimerSet schedules a delay timer. Returns immediately; the body is
// delivered to the owning child when the timer fires.
func (m *timerManager) TimerSet(ownerID string, body, label string, seconds float64) (string, error) {
owner := m.sess.FindChild(ownerID)
if owner == nil {
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID)
}
if seconds < 0 {
return "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer_set: seconds must be ≥ 0")
}
d := time.Duration(seconds * float64(time.Second))
m.mu.Lock()
id := m.mintID()
if label == "" {
label = id
}
t := &pendingTimer{
id: id,
label: label,
body: body,
ownerID: ownerID,
kind: timerKindDelay,
status: timerStatusPending,
firesAt: time.Now().Add(d),
}
m.timers[id] = t
m.mu.Unlock()
t.rt = time.AfterFunc(d, func() { m.fireDelay(id) })
return id, nil
}
func (m *timerManager) fireDelay(id string) {
m.mu.Lock()
t, ok := m.timers[id]
if !ok || t.status != timerStatusPending {
m.mu.Unlock()
return
}
t.status = timerStatusFired
owner := m.sess.FindChild(t.ownerID)
body, label := t.body, t.label
delete(m.timers, id)
m.mu.Unlock()
m.fireFn(owner, body, label)
}
// TimerFireWhenIdleAny schedules an idle-any timer. Children already
// idle at registration are excluded from satisfaction — only a
// transition into idle by a still-active watched child fires the
// timer. Max-wait, when positive, acts as a fallback fire deadline.
func (m *timerManager) TimerFireWhenIdleAny(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
return m.registerIdleTimer(timerKindIdleAny, ownerID, body, label, watched, maxWait)
}
// TimerFireWhenIdleAll schedules an idle-all timer. Already-idle
// children count as satisfied; if every watched child is already idle
// at registration time the response is "already_satisfied" with no
// timer created.
func (m *timerManager) TimerFireWhenIdleAll(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
return m.registerIdleTimer(timerKindIdleAll, ownerID, body, label, watched, maxWait)
}
func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
if m.sess.FindChild(ownerID) == nil {
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID)
}
if len(watched) == 0 {
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "watched must contain at least one process_id")
}
if maxWait < 0 {
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "max_wait_seconds must be ≥ 0")
}
// Validate watched ids and compute the idle baseline up front.
already := make([]string, 0)
waiting := make([]string, 0)
baseline := make(map[string]bool, len(watched))
for _, id := range watched {
c := m.sess.FindChild(id)
if c == nil {
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q in watched", id)
}
if isIdleState(c.IdleState()) {
already = append(already, id)
baseline[id] = true
} else {
waiting = append(waiting, id)
}
}
resp := mcp.TimerFireWhenIdleResponse{AlreadyIdle: already, WaitingOn: waiting}
// idle_all: if all watched are already idle, satisfy synchronously
// — Solo semantics; no pending timer is created.
if kind == timerKindIdleAll && len(waiting) == 0 {
resp.Status = "already_satisfied"
owner := m.sess.FindChild(ownerID)
go m.fireFn(owner, body, label)
return resp, nil
}
m.mu.Lock()
id := m.mintID()
if label == "" {
label = id
}
t := &pendingTimer{
id: id,
label: label,
body: body,
ownerID: ownerID,
kind: kind,
status: timerStatusPending,
watched: append([]string(nil), watched...),
idleBaseline: baseline,
}
if maxWait > 0 {
d := time.Duration(maxWait * float64(time.Second))
t.firesAt = time.Now().Add(d)
t.rt = time.AfterFunc(d, func() { m.fireIdleMaxWait(id) })
}
m.timers[id] = t
m.mu.Unlock()
resp.ID = id
resp.Status = "pending"
return resp, nil
}
func (m *timerManager) fireIdleMaxWait(id string) {
m.mu.Lock()
t, ok := m.timers[id]
if !ok || t.status != timerStatusPending {
m.mu.Unlock()
return
}
t.status = timerStatusFired
owner := m.sess.FindChild(t.ownerID)
body, label := t.body, t.label
delete(m.timers, id)
m.mu.Unlock()
m.fireFn(owner, body, label)
}
// onChildStateChanged evaluates every pending idle_any / idle_all
// timer whenever any child's IdleState flips. Cheap — there are few
// pending timers and the per-tick check is just a map lookup + a slice
// scan.
func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
if !isIdleState(state) {
return
}
m.mu.Lock()
type firing struct {
owner *Child
body string
label string
}
var fires []firing
var firedIDs []string
for _, t := range m.timers {
if t.status != timerStatusPending {
continue
}
if !contains(t.watched, childID) {
continue
}
switch t.kind {
case timerKindIdleAny:
if t.idleBaseline[childID] {
continue // already idle at registration; excluded
}
t.status = timerStatusFired
if t.rt != nil {
t.rt.Stop()
}
fires = append(fires, firing{
owner: m.sess.FindChild(t.ownerID),
body: t.body,
label: t.label,
})
firedIDs = append(firedIDs, t.id)
case timerKindIdleAll:
if m.allWatchedIdleLocked(t) {
t.status = timerStatusFired
if t.rt != nil {
t.rt.Stop()
}
fires = append(fires, firing{
owner: m.sess.FindChild(t.ownerID),
body: t.body,
label: t.label,
})
firedIDs = append(firedIDs, t.id)
}
}
}
for _, id := range firedIDs {
delete(m.timers, id)
}
m.mu.Unlock()
for _, f := range fires {
m.fireFn(f.owner, f.body, f.label)
}
}
// 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.
func (m *timerManager) allWatchedIdleLocked(t *pendingTimer) bool {
for _, id := range t.watched {
c := m.sess.FindChild(id)
if c == nil {
continue // disappeared; treat as satisfied so we don't hang
}
if !isIdleState(c.IdleState()) {
return false
}
}
return true
}
// 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 {
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 {
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.
return nil
}
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
return nil
}
// TimerPause stops the delay clock (or detaches the idle watch) but
// 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 {
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 {
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status != timerStatusPending {
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
}
if t.rt != nil {
t.pausedRemaining = time.Until(t.firesAt)
if t.pausedRemaining < 0 {
t.pausedRemaining = 0
}
t.rt.Stop()
t.rt = nil
// For idle_* timers, only the max-wait timer rides on rt — the
// idle-evaluation path lives in onChildStateChanged. Mark the
// pause so resume rearms the right thing.
t.pausedWasMaxWait = t.kind != timerKindDelay
}
t.status = timerStatusPaused
return nil
}
// TimerResume re-arms a paused timer. For delay timers the remaining
// duration is restored; idle-* timers re-attach to the state-change
// watch list, and any remaining max-wait clock resumes.
//
// Idle-* timers also re-check their satisfaction condition immediately
// on resume: idle transitions that occurred while paused are otherwise
// missed (onChildStateChanged only sees future flips), so a child that
// went idle during the pause window would never fire the timer. For
// idle_any we look for any non-baseline watched child currently idle;
// for idle_all we check whether every watched child is now idle.
func (m *timerManager) TimerResume(ownerID, id string) error {
m.mu.Lock()
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 != timerStatusPaused {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id)
}
t.status = timerStatusPending
if t.pausedRemaining > 0 {
t.firesAt = time.Now().Add(t.pausedRemaining)
switch t.kind {
case timerKindDelay:
localID := id
t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireDelay(localID) })
default:
localID := id
t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireIdleMaxWait(localID) })
}
t.pausedRemaining = 0
t.pausedWasMaxWait = false
}
// For idle-* timers, evaluate the condition right now in case a
// watched child went idle while paused.
var fireNow bool
var owner *Child
var body, label string
switch t.kind {
case timerKindIdleAny:
for _, wid := range t.watched {
if t.idleBaseline[wid] {
continue
}
c := m.sess.FindChild(wid)
if c != nil && isIdleState(c.IdleState()) {
fireNow = true
break
}
}
case timerKindIdleAll:
if m.allWatchedIdleLocked(t) {
fireNow = true
}
}
if fireNow {
t.status = timerStatusFired
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
owner = m.sess.FindChild(t.ownerID)
body, label = t.body, t.label
delete(m.timers, id)
}
m.mu.Unlock()
if fireNow {
m.fireFn(owner, body, label)
}
return nil
}
// TimerList returns timers owned by ownerID, oldest-first. An empty
// ownerID lists every active timer — the top-level orchestrator view.
func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]mcp.TimerInfo, 0)
for _, t := range m.timers {
if ownerID != "" && t.ownerID != ownerID {
continue
}
if t.status != timerStatusPending && t.status != timerStatusPaused {
continue
}
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...),
}
if t.status == timerStatusPending && !t.firesAt.IsZero() {
info.FiresAtUnixMS = t.firesAt.UnixMilli()
}
if t.status == timerStatusPaused && t.pausedRemaining > 0 {
info.PausedRemainingMS = t.pausedRemaining.Milliseconds()
}
out = append(out, info)
}
return out
}
// 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.
func (m *timerManager) activeForChild(id string) *mcp.TimerInfo {
m.mu.Lock()
defer m.mu.Unlock()
var best *pendingTimer
for _, t := range m.timers {
if t.status != timerStatusPending && t.status != timerStatusPaused {
continue
}
if t.ownerID != id && !contains(t.watched, id) {
continue
}
if best == nil {
best = t
continue
}
if t.firesAt.Before(best.firesAt) && !t.firesAt.IsZero() {
best = t
}
}
if best == nil {
return nil
}
info := mcp.TimerInfo{
ID: best.id,
Label: best.label,
Kind: string(best.kind),
Status: best.status,
OwnerID: best.ownerID,
}
if best.status == timerStatusPending && !best.firesAt.IsZero() {
info.FiresAtUnixMS = best.firesAt.UnixMilli()
}
if best.status == timerStatusPaused {
info.PausedRemainingMS = best.pausedRemaining.Milliseconds()
}
return &info
}
func isIdleState(s IdleState) bool {
return s == StateIdle
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}

413
internal/app/timers_test.go Normal file
View File

@@ -0,0 +1,413 @@
package app
import (
"sync"
"testing"
"time"
)
// recorderFire collects timer firings without touching a PTY. Lets the
// timer manager run end-to-end logic in unit tests.
type recorderFire struct {
mu sync.Mutex
fires []recordedFire
}
type recordedFire struct {
OwnerID string
Body string
Label string
}
func (r *recorderFire) fn(owner *Child, body, label string) {
r.mu.Lock()
defer r.mu.Unlock()
id := ""
if owner != nil {
id = owner.ID
}
r.fires = append(r.fires, recordedFire{OwnerID: id, Body: body, Label: label})
}
func (r *recorderFire) snapshot() []recordedFire {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedFire, len(r.fires))
copy(out, r.fires)
return out
}
// fakeChild constructs a Child shell suitable for timer-manager tests.
// Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is
// never reached.
func fakeChild(id string) *Child {
c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "")
running := StatusRunning
c.status.Store(&running)
return c
}
// addChild bypasses Spawn (no PTY needed) so the manager can find the
// child by id and read its IdleState.
func addChild(s *Session, c *Child) {
s.mu.Lock()
s.children[c.ID] = c
s.order = append(s.order, c.ID)
s.mu.Unlock()
}
func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) {
t.Helper()
sess := NewSession(t.TempDir(), "test")
mgr := newTimerManager(sess)
rec := &recorderFire{}
mgr.fireFn = rec.fn
return sess, mgr, rec
}
func TestTimerSetDelivers(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
if id == "" {
t.Fatal("empty timer id")
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
got := rec.snapshot()
if len(got) != 1 {
t.Fatalf("got %d fires, want 1", len(got))
}
if got[0].Body != "wake up" || got[0].OwnerID != "p_owner" {
t.Fatalf("unexpected fire: %+v", got[0])
}
}
func TestTimerIdleAllAlreadySatisfied(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)
idle := StateIdle
a.idleState.Store(&idle)
b.idleState.Store(&idle)
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 != "already_satisfied" {
t.Fatalf("status: got %q want already_satisfied", resp.Status)
}
// fire is dispatched on a goroutine; wait briefly.
time.Sleep(50 * time.Millisecond)
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("fires: %+v", got)
}
}
func TestTimerIdleAnyFiresOnTransition(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
// p_a starts busy.
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
// Flip a into idle and deliver the state-change event.
idle := StateIdle
a.idleState.Store(&idle)
mgr.onChildStateChanged("p_a", StateIdle)
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("fires: %+v", got)
}
}
func TestTimerIdleAnyExcludesBaseline(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
idle := StateIdle
a.idleState.Store(&idle)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
// Send a redundant idle transition for p_a; should NOT fire because
// p_a was idle at registration.
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("unexpected fires: %+v", got)
}
}
func TestTimerCancelPauseResume(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
addChild(sess, owner)
// Cancel before fire.
id, _ := mgr.TimerSet("p_owner", "x", "", 0.2)
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("Cancel: %v", err)
}
time.Sleep(300 * time.Millisecond)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("cancel didn't stop fire: %+v", got)
}
// Pause then resume → fire after resume.
id2, _ := mgr.TimerSet("p_owner", "y", "", 0.2)
time.Sleep(50 * time.Millisecond)
if err := mgr.TimerPause("p_owner", id2); err != nil {
t.Fatalf("Pause: %v", err)
}
time.Sleep(300 * time.Millisecond) // would have fired by now if not paused
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("paused timer fired: %+v", got)
}
if err := mgr.TimerResume("p_owner", id2); err != nil {
t.Fatalf("Resume: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "y" {
t.Fatalf("resume fire: %+v", got)
}
}
func TestTimerOwnershipEnforced(t *testing.T) {
sess, mgr, _ := newTestManager(t)
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, a)
addChild(sess, b)
id, _ := mgr.TimerSet("p_a", "hi", "", 60)
if err := mgr.TimerCancel("p_b", id); err == nil {
t.Fatal("expected ownership error from foreign cancel")
}
if err := mgr.TimerPause("p_b", id); err == nil {
t.Fatal("expected ownership error from foreign pause")
}
}
// TestTimerResumeRechecksIdleAll covers the case where every watched
// child becomes idle while an idle_all timer is paused. Without a resume
// re-check, the timer would stay pending forever because the state
// transitions happened during the pause window.
func TestTimerResumeRechecksIdleAll(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)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
// Both watched children become idle WHILE THE TIMER IS PAUSED, so
// onChildStateChanged is not consulted for this timer.
idle := StateIdle
a.idleState.Store(&idle)
b.idleState.Store(&idle)
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("expected fire on resume, got: %+v", got)
}
}
// TestTimerResumeRechecksIdleAny covers the same missed-transition shape
// for idle_any: a non-baseline watched child going idle during pause must
// fire on resume.
func TestTimerResumeRechecksIdleAny(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", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
idle := StateIdle
a.idleState.Store(&idle)
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("expected fire on resume, got: %+v", got)
}
}
// TestTimerResumeIdleAnyExcludesBaselineDuringPause guards against a
// resume re-check firing for a watcher that was idle at registration
// (and therefore part of the baseline) — only non-baseline transitions
// should satisfy idle_any.
func TestTimerResumeIdleAnyExcludesBaselineDuringPause(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)
idle := StateIdle
working := StateWorking
a.idleState.Store(&idle) // baseline: already idle
b.idleState.Store(&working) // not baseline
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
// b stays working — only a is idle, and a was baseline. Resume
// must not fire.
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("unexpected fire on resume: %+v", got)
}
}
// TestTimerRecordsRemovedOnFire ensures fired delay timers don't leak
// in the timer registry — bodies and metadata must be released.
func TestTimerRecordsRemovedOnFire(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if len(rec.snapshot()) != 1 {
t.Fatalf("timer didn't fire")
}
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
count := len(mgr.timers)
mgr.mu.Unlock()
if stillThere {
t.Fatalf("fired timer %s was not removed from registry", id)
}
if count != 0 {
t.Fatalf("timer registry not drained: %d entries", count)
}
}
// TestTimerRecordsRemovedOnCancel ensures canceled timers are dropped
// from the registry.
func TestTimerRecordsRemovedOnCancel(t *testing.T) {
sess, mgr, _ := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "x", "", 60)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("Cancel: %v", err)
}
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("canceled timer %s was not removed from registry", id)
}
}
// TestTimerRecordsRemovedOnIdleFire ensures idle_* timers are dropped
// from the registry once they fire via onChildStateChanged.
func TestTimerRecordsRemovedOnIdleFire(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", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
idle := StateIdle
a.idleState.Store(&idle)
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 1 {
t.Fatalf("expected fire, got: %+v", got)
}
mgr.mu.Lock()
_, stillThere := mgr.timers[resp.ID]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("fired idle timer %s was not removed from registry", resp.ID)
}
}

View File

@@ -96,17 +96,24 @@ func firstRunningAgentID(children []*Child) string {
}
// processList returns every top-level command/terminal entry in spawn
// order, regardless of running state. The Processes sidebar section
// keeps showing exited entries so the user can see what just died (and
// because Session retains KindCommand entries for restart).
// order. Exited KindCommand entries remain visible so the user can see
// what just died and reach restart_process; exited KindTerminal entries
// are filtered out because terminals are ephemeral and have no restart
// path (Session also drops them in reapChild — this filter is defensive
// for any window between exit and deletion).
func processList(children []*Child) []*Child {
out := make([]*Child, 0, len(children))
for _, c := range children {
if c.ParentID != "" {
continue
}
if c.Kind == KindCommand || c.Kind == KindTerminal {
switch c.Kind {
case KindCommand:
out = append(out, c)
case KindTerminal:
if c.Status() == StatusRunning {
out = append(out, c)
}
}
}
return out

View File

@@ -33,6 +33,14 @@ type viewportRenderer struct {
// cache so the next drawSidebar repaints over the clobber.
scrolled bool
// childOnAlt tracks whether the focused child has entered its
// alternate screen (via ?47 / ?1047 / ?1049). Used to gate mouse-
// tracking-mode forwarding to the host: filter on primary so
// patterm's wheel-scrollback stays armed, forward on alt so codex
// (which disables mouse) lets the user select text and vim (which
// enables it) still gets mouse events.
childOnAlt bool
// skipUTF8 is set when the current multi-byte UTF-8 character started
// past the viewport's right edge. The starter byte was dropped, so
// the remaining continuation bytes must be dropped too instead of
@@ -65,6 +73,16 @@ func newViewportRenderer(l terminalLayout) *viewportRenderer {
return vr
}
// SetChildOnAlt seeds the renderer's view of the focused child's screen
// side. Used when a new renderer is constructed for an already-running
// child whose alt-screen transition we missed, so subsequent mouse-mode
// toggles are filtered/forwarded according to the right side.
func (vr *viewportRenderer) SetChildOnAlt(onAlt bool) {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.childOnAlt = onAlt
}
func (vr *viewportRenderer) SetLayout(l terminalLayout) {
vr.mu.Lock()
defer vr.mu.Unlock()
@@ -236,15 +254,36 @@ func (vr *viewportRenderer) emitCSI() {
return
}
if isAltScreenMode(params) {
// Track the child's screen side so we know whether to filter
// or forward subsequent mouse-mode toggles. Entering alt
// disables host mouse reporting by default so codex (and
// any other alt-screen TUI that doesn't request mouse)
// allows the user to click-drag to select text. Alt-screen
// TUIs that want mouse (vim, less with -X) re-enable it
// via ?1000h after switching to alt — the forwarder below
// passes that through. Leaving alt re-arms host mouse for
// primary-screen wheel-scrollback.
wasAlt := vr.childOnAlt
vr.childOnAlt = final == 'h'
if !wasAlt && vr.childOnAlt {
vr.pending.WriteString("\x1b[?1000l\x1b[?1006l")
}
if wasAlt && !vr.childOnAlt {
vr.pending.WriteString("\x1b[?1000h\x1b[?1006h")
}
return
}
if isMouseTrackingMode(params) {
// Patterm owns mouse reporting on the host so wheel events keep
// flowing for scroll-viewport. The child's own emulator still
// observes the mode set/reset (it processes the same bytes we
// hand to ghostty_terminal_vt_write), so we know whether the
// child wants mouse input — we just don't let it disarm our
// host listener.
// On the child's primary screen patterm owns mouse reporting so
// wheel events keep flowing for in-pane scrollback — drop the
// child's toggle. On the alt screen the child should be free
// to enable mouse (vim, less) or disable it (codex); we forward
// the toggle to the host so click-and-drag selection works for
// alt-screen TUIs that don't want mouse, and mouse-aware ones
// still see the events they need.
if vr.childOnAlt {
vr.pending.Write(vr.buf)
}
return
}
}

View File

@@ -24,8 +24,36 @@ func TestViewportRendererShiftsCursor(t *testing.T) {
func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?1049hb\x1b[?1049lc")))
// The ?1049h/l toggles themselves must not reach the host (patterm
// owns its own alt screen). On the transition we re-sync host mouse
// reporting so codex (which doesn't request mouse) lets the user
// drag-select; leaving alt re-arms it for primary-screen wheel
// scrollback.
want := "a\x1b[?1000l\x1b[?1006lb\x1b[?1000h\x1b[?1006hc"
if got != want {
t.Fatalf("alt-screen toggles: got %q want %q", got, want)
}
}
func TestViewportRendererMouseTrackingFilteredOnPrimary(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?1000lb\x1b[?1000hc")))
if got != "abc" {
t.Fatalf("alt-screen toggles: got %q", got)
t.Fatalf("mouse mode on primary should be filtered: got %q", got)
}
}
func TestViewportRendererMouseTrackingForwardedOnAlt(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// Enter alt; subsequent mouse-mode toggles should reach the host so
// alt-screen TUIs (vim, less) can run with mouse on, and selection-
// using ones (codex) stay with mouse off.
got := string(vr.Render([]byte("\x1b[?1049h\x1b[?1000lx\x1b[?1000hy")))
if !strings.Contains(got, "\x1b[?1000l") {
t.Fatalf("alt-screen mouse disable should reach host: %q", got)
}
if !strings.Contains(got, "\x1b[?1000h") {
t.Fatalf("alt-screen mouse enable should reach host: %q", got)
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"regexp"
"strings"
"time"
)
type Event struct {
@@ -175,6 +176,41 @@ func runStep(s *Session, step Step, results map[string]json.RawMessage) error {
return fmt.Errorf("no saved result %q", step.From)
}
return assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring)
case "wait_until_mcp":
// Poll an MCP method until the assertion at Path holds (or
// Contains substring matches), or TimeoutMS elapses. Used by the
// idle-detection scenarios to wait for a child's idle_state to
// reach a target value without sprinkling sleeps.
params, perr := resolveParams(step.Params, results)
if perr != nil {
return perr
}
deadline := time.Now().Add(timeoutMS(step.TimeoutMS))
var lastRaw json.RawMessage
var lastErr error
for {
raw, err := s.MCPCall(step.Method, params)
if err == nil {
if aerr := assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring); aerr == nil {
if step.SaveAs != "" {
results[step.SaveAs] = raw
}
return nil
} else {
lastErr = aerr
lastRaw = raw
}
} else {
lastErr = err
}
if time.Now().After(deadline) {
if lastErr != nil {
return fmt.Errorf("wait_until_mcp timeout: %w (last response: %s)", lastErr, string(lastRaw))
}
return fmt.Errorf("wait_until_mcp timeout (no successful call)")
}
time.Sleep(100 * time.Millisecond)
}
}
return fmt.Errorf("unknown step type %q", step.Type)
}

View File

@@ -25,11 +25,23 @@ type ScenarioPresets struct {
}
type ScenarioPreset struct {
Name string `json:"name"`
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Shell bool `json:"shell,omitempty"`
Name string `json:"name"`
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Shell bool `json:"shell,omitempty"`
IdleDetection *ScenarioIdleDetection `json:"idle_detection,omitempty"`
}
// ScenarioIdleDetection mirrors preset.IdleDetection so scenarios can
// configure per-strategy idle detection for fake agent presets.
type ScenarioIdleDetection struct {
Strategy string `json:"strategy,omitempty"`
IdleThresholdMS int `json:"idle_threshold_ms,omitempty"`
TitleStatusMap map[string]string `json:"title_status_map,omitempty"`
PermissionPatterns []string `json:"permission_patterns,omitempty"`
ThinkingPatterns []string `json:"thinking_patterns,omitempty"`
ErrorPatterns []string `json:"error_patterns,omitempty"`
}
type ScenarioScript struct {

View File

@@ -0,0 +1,44 @@
{
"name": "idle_osc_title_stability",
"presets": {
"processes": [
{
"name": "titler",
"argv": [
"sh",
"-lc",
"i=0; while [ $i -lt 6 ]; do printf '\\033]2;step %d\\007' $i; i=$((i+1)); sleep 0.2; done; sleep 60"
],
"idle_detection": {
"strategy": "osc_title_stability",
"idle_threshold_ms": 1000
}
}
]
},
"trust": ["titler"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "titler", "name": "titler"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "working",
"timeout_ms": 3000
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "idle",
"timeout_ms": 4000
}
]
}

View File

@@ -0,0 +1,48 @@
{
"name": "idle_osc_title_status",
"presets": {
"processes": [
{
"name": "geminilike",
"argv": [
"sh",
"-lc",
"printf '\\033]2;Thinking\\007'; sleep 1; printf '\\033]2;Permission required\\007'; sleep 60"
],
"idle_detection": {
"strategy": "osc_title_status",
"idle_threshold_ms": 1000,
"title_status_map": {
"thinking": "thinking",
"permission": "permission"
}
}
}
]
},
"trust": ["geminilike"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "geminilike", "name": "geminilike"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "thinking",
"timeout_ms": 3000
},
{
"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,44 @@
{
"name": "idle_output_activity",
"presets": {
"processes": [
{
"name": "blinker",
"argv": ["sh", "-lc", "echo step1; sleep 3; echo step2; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 1000
}
}
]
},
"trust": ["blinker"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {
"kind": "command",
"preset": "blinker",
"name": "blinker"
},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "working",
"timeout_ms": 4000
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "idle",
"timeout_ms": 4000
}
]
}

View File

@@ -0,0 +1,33 @@
{
"name": "idle_regex_promote",
"presets": {
"processes": [
{
"name": "approver",
"argv": ["sh", "-lc", "echo 'Do you want to proceed?'; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500,
"permission_patterns": ["Do you want to proceed\\?"]
}
}
]
},
"trust": ["approver"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "approver", "name": "approver"},
"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,44 @@
{
"name": "timer_cancel",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
}
]
},
"trust": ["echoer"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "proc"
},
{ "type": "wait_stable", "timeout_ms": 1500 },
{
"type": "mcp_call",
"method": "timer_set",
"params": {"seconds": 1, "body": "should-not-arrive", "owner_process_id": "{{proc.process_id}}"},
"save_as": "tmr"
},
{
"type": "mcp_call",
"method": "timer_cancel",
"params": {"timer_id": "{{tmr.timer_id}}"}
},
{
"type": "mcp_call",
"method": "timer_list",
"params": {"owner_process_id": "{{proc.process_id}}"},
"save_as": "listed"
},
{
"type": "assert_saved",
"from": "listed",
"path": "",
"equals": []
}
]
}

View File

@@ -0,0 +1,48 @@
{
"name": "timer_idle_all_already_satisfied",
"presets": {
"processes": [
{
"name": "quiet",
"argv": ["sh", "-lc", "echo ready; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500
}
}
]
},
"trust": ["quiet"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "quiet", "name": "quiet"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "idle",
"timeout_ms": 4000
},
{
"type": "mcp_call",
"method": "timer_fire_when_idle_all",
"params": {
"watched": ["{{proc.process_id}}"],
"body": "all-idle",
"owner_process_id": "{{proc.process_id}}"
},
"save_as": "resp"
},
{
"type": "assert_saved",
"from": "resp",
"path": "status",
"equals": "already_satisfied"
}
]
}

View File

@@ -0,0 +1,89 @@
{
"name": "timer_idle_all_pending",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
},
{
"name": "quiet",
"argv": ["sh", "-lc", "echo ready; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500
}
},
{
"name": "busy",
"argv": ["sh", "-lc", "for i in 1 2 3 4 5; do echo tick $i; sleep 0.2; done; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500
}
}
]
},
"trust": ["echoer", "quiet", "busy"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "owner"
},
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "quiet", "name": "quiet"},
"save_as": "q"
},
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "busy", "name": "busy"},
"save_as": "b"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{q.process_id}}"},
"path": "idle_state",
"equals": "idle",
"timeout_ms": 3000
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{b.process_id}}"},
"path": "idle_state",
"equals": "working",
"timeout_ms": 3000
},
{
"type": "mcp_call",
"method": "timer_fire_when_idle_all",
"params": {
"watched": ["{{q.process_id}}", "{{b.process_id}}"],
"body": "all-idle",
"owner_process_id": "{{owner.process_id}}"
},
"save_as": "resp"
},
{
"type": "assert_saved",
"from": "resp",
"path": "status",
"equals": "pending"
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {"process_id": "{{owner.process_id}}", "mode": "grid"},
"path": "content",
"contains": "saw:all-idle",
"allow_substring": true,
"timeout_ms": 6000
}
]
}

View File

@@ -0,0 +1,67 @@
{
"name": "timer_idle_any_fires_on_transition",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
},
{
"name": "busy",
"argv": ["sh", "-lc", "for i in 1 2 3 4 5; do echo tick $i; sleep 0.2; done; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500
}
}
]
},
"trust": ["echoer", "busy"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "owner"
},
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "busy", "name": "busy"},
"save_as": "watch"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{watch.process_id}}"},
"path": "idle_state",
"equals": "working",
"timeout_ms": 3000
},
{
"type": "mcp_call",
"method": "timer_fire_when_idle_any",
"params": {
"watched": ["{{watch.process_id}}"],
"body": "any-idle",
"owner_process_id": "{{owner.process_id}}"
},
"save_as": "resp"
},
{
"type": "assert_saved",
"from": "resp",
"path": "status",
"equals": "pending"
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {"process_id": "{{owner.process_id}}", "mode": "grid"},
"path": "content",
"contains": "saw:any-idle",
"allow_substring": true,
"timeout_ms": 6000
}
]
}

View File

@@ -0,0 +1,62 @@
{
"name": "timer_pause_resume",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
}
]
},
"trust": ["echoer"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "proc"
},
{ "type": "wait_stable", "timeout_ms": 1500 },
{
"type": "mcp_call",
"method": "timer_set",
"params": {
"seconds": 1,
"body": "after-resume",
"owner_process_id": "{{proc.process_id}}"
},
"save_as": "tmr"
},
{
"type": "mcp_call",
"method": "timer_pause",
"params": {"timer_id": "{{tmr.timer_id}}"}
},
{
"type": "mcp_call",
"method": "timer_list",
"params": {"owner_process_id": "{{proc.process_id}}"},
"save_as": "listed"
},
{
"type": "assert_saved",
"from": "listed",
"path": "0.status",
"equals": "paused"
},
{
"type": "mcp_call",
"method": "timer_resume",
"params": {"timer_id": "{{tmr.timer_id}}"}
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {"process_id": "{{proc.process_id}}", "mode": "grid"},
"path": "content",
"contains": "saw:after-resume",
"allow_substring": true,
"timeout_ms": 5000
}
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "timer_set_delivers",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
}
]
},
"trust": ["echoer"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "proc"
},
{ "type": "wait_stable", "timeout_ms": 1500 },
{
"type": "mcp_call",
"method": "timer_set",
"params": {
"seconds": 0.5,
"body": "hello-from-timer",
"owner_process_id": "{{proc.process_id}}"
},
"save_as": "tmr"
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {"process_id": "{{proc.process_id}}", "mode": "grid"},
"path": "content",
"contains": "saw:hello-from-timer",
"allow_substring": true,
"timeout_ms": 5000
}
]
}

View File

@@ -27,6 +27,24 @@ var serverInfo = map[string]any{
"version": "0.1.0",
}
// serverInstructions is returned in the MCP `initialize` response. MCP
// clients show this to the underlying LLM as context for how to use
// the server. Failure modes we've seen and want to head off:
// - The agent assumes patterm is something it has to launch (running
// `patterm` or `patterm mcp-stdio` from its own shell). It's
// already attached — it just calls the tools.
// - The agent reaches for shell tools (perl / nc / socat / curl) to
// poke patterm's Unix socket directly. That socket connection
// carries no caller identity, so any sub-agent the agent spawns
// that way ends up as a stray top-level tab instead of a child
// under the spawning agent. Always go through the MCP tools.
// - The agent shells out to `claude` / `codex` / `opencode` to start
// a peer instead of calling `spawn_agent`. Those peers won't show
// 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."
// toolDescriptor is the shape returned by `tools/list`. inputSchema is
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
// for each tool, which lets MCP clients accept arbitrary arguments and
@@ -73,6 +91,14 @@ func booleanProp(desc string) map[string]any {
return map[string]any{"type": "boolean", "description": desc}
}
func arrayOfStringsProp(desc string) map[string]any {
return map[string]any{
"type": "array",
"description": desc,
"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.
@@ -80,7 +106,7 @@ func toolCatalog() []toolDescriptor {
return []toolDescriptor{
{
Name: "spawn_agent",
Description: "Spawn a sub-agent from an agent preset and optionally seed it with initial instructions. 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('lifecycle').",
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').",
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."),
@@ -239,12 +265,70 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "timer_wait",
Description: "Sleep server-side for `seconds` and return a timer id (use to pace polling).",
Description: "Schedule a delay timer that injects a fixed `[system]` line into your pane when it fires (legacy; prefer timer_set).",
InputSchema: objectSchema(map[string]any{
"seconds": numberProp("Sleep duration."),
"seconds": numberProp("Delay duration."),
"label": stringProp("Optional label for diagnostics."),
}, []string{"seconds"}),
},
{
Name: "timer_set",
Description: "Schedule a one-shot delay timer that delivers `body` to the owning agent as a fresh user turn when it fires.",
InputSchema: objectSchema(map[string]any{
"seconds": numberProp("Delay duration."),
"body": stringProp("Message delivered verbatim to the owning agent as a user turn when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"owner_process_id": stringProp("Owner process id; defaults to the caller. Top-level callers must supply this explicitly."),
}, []string{"seconds", "body"}),
},
{
Name: "timer_fire_when_idle_any",
Description: "Schedule a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"max_wait_seconds": numberProp("Optional cap; 0 means no fallback fire."),
"owner_process_id": stringProp("Owner process id; defaults to the caller."),
}, []string{"watched", "body"}),
},
{
Name: "timer_fire_when_idle_all",
Description: "Schedule a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"max_wait_seconds": numberProp("Optional cap; 0 means no fallback fire."),
"owner_process_id": stringProp("Owner process id; defaults to the caller."),
}, []string{"watched", "body"}),
},
{
Name: "timer_cancel",
Description: "Cancel one pending timer owned by the caller.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id returned by a previous timer_* call."),
}, []string{"timer_id"}),
},
{
Name: "timer_pause",
Description: "Pause one pending timer owned by the caller. Idle-aware timers stop listening to state changes; delay timers preserve their remaining wait.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id."),
}, []string{"timer_id"}),
},
{
Name: "timer_resume",
Description: "Resume one paused timer owned by the caller.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id."),
}, []string{"timer_id"}),
},
{
Name: "timer_list",
Description: "List pending and paused timers owned by the caller.",
InputSchema: objectSchema(nil, nil),
},
{
Name: "scratchpad_list",
Description: "List shared per-project scratchpad entries.",
@@ -311,7 +395,8 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
"capabilities": map[string]any{
"tools": map[string]any{"listChanged": false},
},
"serverInfo": serverInfo,
"serverInfo": serverInfo,
"instructions": serverInstructions,
}
return result, true, 0, "", nil

View File

@@ -36,6 +36,13 @@ func TestInitializeReturnsCapabilities(t *testing.T) {
if caps["tools"] == nil {
t.Fatalf("tools capability missing: %+v", caps)
}
// patterm-specific orientation: clients show this to the underlying
// LLM, so it's our primary hook for steering vendor TUIs (codex in
// particular) toward the MCP tool surface instead of shell-ing out.
instructions, ok := parsed.Result["instructions"].(string)
if !ok || instructions == "" {
t.Fatalf("instructions missing or wrong type: %+v", parsed.Result)
}
}
func TestInitializedNotificationSuppressesResponse(t *testing.T) {

View File

@@ -88,6 +88,13 @@ type ToolHost interface {
SendMessage(callerID, targetID, message string) error
RequestHumanAttention(callerID, processID, reason string) error
TimerWait(callerID string, seconds float64, label string) (string, error)
TimerSet(callerID string, args TimerSetArgs) (TimerHandle, error)
TimerFireWhenIdleAny(callerID string, args TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error)
TimerFireWhenIdleAll(callerID string, args TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error)
TimerCancel(callerID, id string) error
TimerPause(callerID, id string) error
TimerResume(callerID, id string) error
TimerList(callerID string) ([]TimerInfo, error)
// Scratchpads.
ScratchpadList() ([]scratchpad.Entry, error)
@@ -111,6 +118,13 @@ type ProcessInfo struct {
ExitCode *int `json:"exit_code,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"`
Trusted *bool `json:"trusted,omitempty"`
// IdleState is the idle-detection classifier's current opinion:
// one of "idle", "working", "thinking", "permission", "error".
// Empty when the classifier has not yet evaluated this child
// (typically right after spawn) or when idle detection is disabled.
IdleState string `json:"idle_state,omitempty"`
IdleReason string `json:"idle_reason,omitempty"`
}
// ProcessStatus is what get_process_status returns. Richer than
@@ -181,6 +195,63 @@ type SearchMatch struct {
Text string `json:"text"`
}
// TimerSetArgs is the input for timer_set: a one-shot delay timer that
// delivers Body to the owning agent as a fresh user turn when it fires.
// OwnerProcessID is optional — when empty the caller's own process_id
// is used (matching Solo's "bound agent" semantics). Top-level
// orchestrators (no caller identity) must set OwnerProcessID
// explicitly.
type TimerSetArgs struct {
Body string `json:"body"`
Label string `json:"label,omitempty"`
Seconds float64 `json:"seconds"`
OwnerProcessID string `json:"owner_process_id,omitempty"`
}
// TimerFireWhenIdleArgs is the input for timer_fire_when_idle_any /
// timer_fire_when_idle_all. Watched lists process_ids to monitor.
// MaxWaitSeconds bounds how long the timer can stay pending before
// firing anyway (0 = no max wait, fire only when the idle condition is
// met). OwnerProcessID: see TimerSetArgs.
type TimerFireWhenIdleArgs struct {
Body string `json:"body"`
Label string `json:"label,omitempty"`
Watched []string `json:"watched"`
MaxWaitSeconds float64 `json:"max_wait_seconds,omitempty"`
OwnerProcessID string `json:"owner_process_id,omitempty"`
}
// TimerHandle is the response for timer_set.
type TimerHandle struct {
ID string `json:"timer_id"`
}
// TimerFireWhenIdleResponse covers timer_fire_when_idle_any /
// timer_fire_when_idle_all. When every watched process is already idle
// at registration time, idle_all returns Status="already_satisfied"
// and ID="" — no timer is created (matches Solo). idle_any returns
// AlreadyIdle so the caller can see which processes were excluded from
// satisfaction.
type TimerFireWhenIdleResponse struct {
ID string `json:"timer_id,omitempty"`
Status string `json:"status"` // "pending" | "already_satisfied"
AlreadyIdle []string `json:"already_idle,omitempty"`
WaitingOn []string `json:"waiting_on,omitempty"`
}
// TimerInfo is one row in the timer_list response.
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"
OwnerID string `json:"owner_process_id"`
WatchedIDs []string `json:"watched,omitempty"`
FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"`
PausedRemainingMS int64 `json:"paused_remaining_ms,omitempty"`
}
// PortSighting matches the per-child store in internal/app.
type PortSighting struct {
Port int `json:"port"`
@@ -575,6 +646,82 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
}
return map[string]string{"timer_id": id}, 0, "", nil
case "timer_set":
var p TimerSetArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
h2, err := h.TimerSet(callerID, p)
if err != nil {
return mapToolError(err)
}
return h2, 0, "", nil
case "timer_fire_when_idle_any":
var p TimerFireWhenIdleArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
resp, err := h.TimerFireWhenIdleAny(callerID, p)
if err != nil {
return mapToolError(err)
}
return resp, 0, "", nil
case "timer_fire_when_idle_all":
var p TimerFireWhenIdleArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
resp, err := h.TimerFireWhenIdleAll(callerID, p)
if err != nil {
return mapToolError(err)
}
return resp, 0, "", nil
case "timer_cancel":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerCancel(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_pause":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerPause(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_resume":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerResume(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_list":
ts, err := h.TimerList(callerID)
if err != nil {
return mapToolError(err)
}
return ts, 0, "", nil
case "scratchpad_list":
entries, err := h.ScratchpadList()
if err != nil {

View File

@@ -40,9 +40,42 @@ type Preset struct {
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"`
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
// agent preset. Independent of ReadySignal (which is startup-only).
// All fields are optional; when the whole block is nil the runtime
// falls back to output_activity with a 2s threshold.
//
// Strategy selects the primary signal:
// - "output_activity": ms since last PTY output (Claude, OpenCode).
// - "osc_title_stability": ms since last OSC 0/2 title change
// (Codex, Amp — title changes mean activity).
// - "osc_title_status": substring-match the current title against
// TitleStatusMap (Gemini — title carries a status word).
//
// Promoter patterns are applied on top of the strategy. They run
// against the recent ring-buffer tail; the first match wins in
// error > permission > thinking precedence and promotes the state
// over whatever the strategy returned.
type IdleDetection struct {
Strategy string `json:"strategy,omitempty"`
IdleThresholdMS int `json:"idle_threshold_ms,omitempty"`
// TitleStatusMap maps a (case-insensitive) substring of the OSC
// title to a state. Only meaningful for "osc_title_status".
// Allowed values: "idle", "working", "thinking", "permission", "error".
TitleStatusMap map[string]string `json:"title_status_map,omitempty"`
// Output regex promoters. Compiled at load time; bad patterns are
// surfaced as warnings and skipped.
PermissionPatterns []string `json:"permission_patterns,omitempty"`
ThinkingPatterns []string `json:"thinking_patterns,omitempty"`
ErrorPatterns []string `json:"error_patterns,omitempty"`
}
// MCPInjection covers the strategies SPEC §10 enumerates plus
@@ -196,6 +229,15 @@ func ensureDefaults(base string) error {
"argv": ["claude"],
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
"ready_signal": { "idle_ms": 1000 },
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 2000,
"permission_patterns": [
"Do you want to proceed\\?",
" 1\\. Yes",
"1\\. Yes, and don't ask"
]
},
"chrome_trim_hints": [
"^Welcome to Claude Code",
"^/help for help",
@@ -220,6 +262,10 @@ func ensureDefaults(base string) error {
"format": "toml"
},
"ready_signal": { "idle_ms": 1000 },
"idle_detection": {
"strategy": "osc_title_stability",
"idle_threshold_ms": 2000
},
"chrome_trim_hints": [
"^OpenAI Codex",
"^\\s*model:",
@@ -243,6 +289,10 @@ func ensureDefaults(base string) error {
"var": "OPENCODE_CONFIG_CONTENT"
},
"ready_signal": { "idle_ms": 1000 },
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 2000
},
"chrome_trim_hints": [
"^\\s*█",
"^\\s*opencode v",
@@ -250,14 +300,6 @@ func ensureDefaults(base string) error {
"^\\s*>_"
]
}
`,
},
{
"presets/processes/shell.json",
`{
"name": "shell",
"argv": ["__SHELL__"]
}
`,
},
}
@@ -269,15 +311,7 @@ func ensureDefaults(base string) error {
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
return err
}
body := d.body
if strings.Contains(body, "__SHELL__") {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
body = strings.ReplaceAll(body, "__SHELL__", shell)
}
if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
if err := os.WriteFile(full, []byte(d.body), 0o600); err != nil {
return err
}
}

View File

@@ -57,6 +57,11 @@ type Emulator interface {
// ActiveScreen reports whether we are on the primary or alternate buffer.
ActiveScreen() (Screen, error)
// Title returns the most recently set window title (OSC 0/2). Returns
// an empty string if no title has been set. Used by idle detection
// for the osc_title_stability and osc_title_status strategies.
Title() (string, error)
// ScrollViewportTop moves the viewport to the top of the scrollback.
ScrollViewportTop() error

View File

@@ -544,6 +544,27 @@ func (e *GhosttyEmulator) Cursor() (CursorState, error) {
return CursorState{Col: uint16(col), Row: uint16(row), Visible: bool(visible)}, nil
}
// Title returns the most recent window title set by OSC 0/2 escape
// sequences. The libghostty-vt API hands back a borrowed pointer that
// stays valid only until the next vt_write/reset, so we copy out to a
// Go string under the same mutex that gates writes. An empty string
// (len=0) means no title has been set.
func (e *GhosttyEmulator) Title() (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return "", errors.New("vt: emulator closed")
}
var s C.GhosttyString
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_TITLE, unsafe.Pointer(&s)); rc != C.GHOSTTY_SUCCESS {
return "", fmt.Errorf("vt: get title failed: %s", ghosttyResultStr(rc))
}
if s.ptr == nil || s.len == 0 {
return "", nil
}
return C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len)), nil
}
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
e.mu.Lock()
defer e.mu.Unlock()

View File

@@ -24,6 +24,7 @@ func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub
func (e *GhosttyEmulator) StyledScreenVT() ([]byte, error) { return nil, errStub }
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
func (e *GhosttyEmulator) Title() (string, error) { return "", errStub }
func (e *GhosttyEmulator) ScrollViewportTop() error { return errStub }
func (e *GhosttyEmulator) ScrollViewportBottom() error { return errStub }
func (e *GhosttyEmulator) ScrollViewportDelta(int) error { return errStub }