64 Commits

Author SHA1 Message Date
4051e7264b docs: mark daemon/client plan as implemented 2026-05-27 14:36:02 +01:00
63986e7e00 Fix data race on PTY master between Read and Close
pumpChild's PTY.Read raced Session.Shutdown's PTY.Close on the master
field (Close set it nil while Read read it). Benign at process exit on
main, but the daemon now runs Shutdown routinely (daemon stop). Guard
the field with a mutex, capturing the fd under the lock and doing the
blocking I/O outside it so Close still unblocks an in-flight Read.

Caught under: go test -race -run 'Daemon|NetClient|Owner' -count=5.
2026-05-27 14:35:33 +01:00
6d15626e05 add per-pane display ownership 2026-05-27 14:30:47 +01:00
63cb8a4388 add tcp daemon listener with token auth 2026-05-27 14:19:14 +01:00
5149224000 attach default client to local daemon 2026-05-27 14:09:51 +01:00
95b1967e9b Fix daemon shutdown hang and concurrent-send race
- daemon_net: close the client transport on context cancellation so the
  per-connection Recv loop unblocks; otherwise wg.Wait() in the accept loop
  hung on a still-connected client and the daemon never stopped.
- protocol: guard ConnTransport.Send with a mutex so the subscriber pump and
  command handlers can push frames concurrently without racing the bufio.Writer.

Fixes TestDaemonDetachReattachPreservesProcess (now passes under -race).
2026-05-27 13:59:47 +01:00
d07a09d64f add local daemon socket protocol 2026-05-27 13:55:38 +01:00
c56de27f44 fix scratchpad routing by caller project 2026-05-27 13:50:17 +01:00
80a14502c4 app: add loopback multi-project registry 2026-05-27 13:40:59 +01:00
08c7405c79 docs: add daemon client implementation plan 2026-05-27 13:25:59 +01:00
ec0c148164 Update PTY start call sites 2026-05-27 13:21:18 +01:00
9aecc8b7a2 Scaffold loopback daemon client split 2026-05-27 13:19:56 +01:00
e63bdad5e1 Add daemon client protocol frames 2026-05-27 13:19:42 +01:00
b72a32bbc6 Fix PTY workdir and process group teardown 2026-05-27 13:19:35 +01:00
da46340a82 Merge pull request 'Work through TODO fixes' (#8) from todo-fixes into main 2026-05-25 13:13:25 +01:00
d2342f99cf Show every agent tab's summary, not just the focused one
The tab bar's row-2 summary was painted only for the active tab. Add a
per-child summaryTextFor/summaryRawFor helper (active variants now
delegate to it), carry each tab's childID on its tabRect, and loop over
all visible tabs so each renders its own summary under its column.
Layout is unchanged (still 3 rows); narrow tabs clip as before.

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

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

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

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

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

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

Resolves the [MCP SCRATCHPAD DELETE] TODO item.
2026-05-25 12:23:58 +01:00
f61788eff2 Work through TODO fixes 2026-05-21 15:45:01 +01:00
c1b66f9f8a Merge pull request 'Show idle state in the top tab bar + release v0.0.7' (#7) from worktree-timers-cancel-on-close into main 2026-05-18 13:25:38 +01:00
fe25fcf043 Release v0.0.7
All checks were successful
release / build-linux-amd64 (push) Successful in 12m5s
2026-05-18 13:02:46 +01:00
2fa00ad510 Show idle state in the top tab bar
Each agent tab now prefixes its label with the same one-rune idle
indicator the sidebar uses (✕ error, ? permission, ◐ thinking, ○ idle,
● working), so the state of every open agent is visible without
opening or focusing each tab. Tab redraws now fire on idle-state
changes in addition to sidebar redraws.
2026-05-18 13:02:35 +01:00
412b1167a2 Cancel pending timers when a child is closed (#6)
Co-authored-by: Harry Bayliss <harry@hjb.dev>
Co-committed-by: Harry Bayliss <harry@hjb.dev>
2026-05-18 12:46:50 +01:00
34b41be1df Cancel pending timers when a child is closed
Stale timer bodies were re-delivered to the orchestrator pane after
the parent had already processed the sub-agent's reply and called
close_process. The timer registry held no link to the child
lifecycle, so timers owned by or watching the closed child lingered
until something triggered a fire — e.g. a trailing classifier tick
for the now-removed child.

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

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

The box stops flickering while the focused child repaints its TUI:
the overlay is stitched onto the per-chunk PTY write under outMu
and bracketed by DECSET 2026 so supporting terminals buffer the
child's redraw and the box paint into a single frame.
2026-05-15 21:24:18 +01:00
ef9b8e71c6 Release v0.0.5
All checks were successful
release / build-linux-amd64 (push) Successful in 11m51s
2026-05-15 20:56:38 +01:00
e64060e40f Calm down the focused-section labels in the command palette
Focused-section rows are now bare verbs (Rename, Close, Stop, Restart,
Delete, Edit) instead of repeating the focused name. The title bar
already carries the subject, and the row hint preserves fuzzy-search
matches like "close codex". Section banners are replaced by a single
blank spacer row so the verbs themselves carry the visual weight,
and the Open section no longer lists "Switch to <current>" for the
pane that's already focused.
2026-05-15 20:30:31 +01:00
e4ab8c2136 Merge pull request 'Add stackable toast notifications' (#5) from worktree-toast-notifications into main 2026-05-15 20:28:10 +01:00
f312b6d345 Add stackable toast notifications
Replaces the single-slot status-line flash with a top-right toast
stack over the focused pane. flashError, flashTransient, and
notifyAttention all push onto the same stack (cap 5, FIFO drop).
Ctrl-N dismisses the most recent toast; empty stack falls through to
the focused PTY so readline / nano / emacs / opencode bindings keep
working. A new "Clear notifications" palette item empties the stack.
2026-05-15 20:26:35 +01:00
e6f5a94fae Trim actioned perf-audit items; add palette polish TODO
Removes the 2026-05-15 perf audit findings that have either shipped
(see CHANGELOG) or are tracked elsewhere, and replaces them with the
remaining palette-refinement notes: generic labels for focused
actions ("Close current agent") and a higher-level concern that the
palette has grown cluttered as features were added.
2026-05-15 19:53:51 +01:00
c1ecba0624 Use mise to install zig + go in release CI; cut 0.0.4
All checks were successful
release / build-linux-amd64 (push) Successful in 13m7s
`mlugg/setup-zig` was chasing mirrors for ~4 minutes on every run
(see v0.0.1 / v0.0.2 logs) and `actions/setup-go` was spending
another ~4 minutes downloading Go before patterm started building.
mise already manages the project's zig pin; adding `go = "1.26.3"`
to `.mise.toml` (matching go.mod) lets `jdx/mise-action@v2` install
both with one cached step. Subsequent runs reuse the mise cache
instead of re-resolving mirror URLs and re-downloading toolchains.

Also adds an `actions/cache@v4` step for `~/.cache/go-build` and
`~/go/pkg/mod` keyed on `go.sum` so `go build` itself doesn't
re-pull modules every tag push.
2026-05-15 19:38:13 +01:00
878e9370bc Fix error flashes replacing focused pane 2026-05-15 19:27:42 +01:00
fd9c19e5c2 Fix release CI: upgrade mlugg/setup-zig to v2 and cut 0.0.3
Some checks failed
release / build-linux-amd64 (push) Has been cancelled
`mlugg/setup-zig@v1` is deprecated and only knows the pre-0.14
tarball name (`zig-linux-x86_64-<ver>.tar.xz`), so every mirror —
and the official ziglang.org/builds — returned 404 for Zig 0.15.2
on both the v0.0.1 and v0.0.2 release runs. v2 uses the new
`zig-x86_64-linux-<ver>.tar.xz` layout that Zig switched to in
0.14+.

Also rolls the existing CHANGELOG `[Unreleased]` work into a
dated `[0.0.3]` section and adds the CI fix to its Fixed list.
2026-05-15 19:14:21 +01:00
6d90cd7185 Match Solo summary cadence options 2026-05-15 19:13:54 +01:00
d648d5b775 Add auto-summary settings 2026-05-15 19:09:21 +01:00
1bf51bb784 Merge pull request 'Overhaul command palette UX' (#4) from feat/palette-ux-overhaul into main
Reviewed-on: #4
2026-05-15 18:25:38 +01:00
81bc77366f Overhaul command palette UX
Six-phase sweep: section headers (Focused / Open / Spawn / Quit) with
header-skip cursor; chip strip mirroring sw/sp/k macros, driven by
Tab; unified Spawn verbs across agent / process / terminal / custom;
dropped duplicate global Close list in favor of Ctrl-X inline close
on a Switch row plus the [Close] chip; scored matching (prefix >
word-boundary > substring > fuzzy) with matched-char highlighting;
title bar surfaces focus subject; rename forms split long subject
onto its own row; new Alt-1..9 quick-pick, Home/End, ? help overlay,
and Ctrl-R relaunch toggle inside the spawn-process form. Scroll
indicator and cursor/total counter round out the footer.
2026-05-15 16:41:44 +01:00
0c960fa859 Clarify sub-agent reply routing in MCP tool descriptions
A sub-agent's reply to send_message lands in the caller's own pane
tagged [sub-agent:<name>], not in the sub-agent's output. The
descriptions for wait_for_pattern, send_message, both
timer_fire_when_idle_*, and the server-instructions preamble now
spell this out, along with the canonical send_message →
timer_fire_when_idle_any → read-own-pane pattern. help('readiness')
and help('coordination') updated to match. Previously agents reached
for wait_for_pattern on the sub-agent and deadlocked until timeout
because the reply had already been delivered to their own pane.
2026-05-15 16:08:07 +01:00
b05065a601 Sync TODO.md perf-audit review pass
Removed low/marginal items from the original sweep; remaining items
have measured or workflow evidence to justify action.
2026-05-15 16:07:58 +01:00
08187aed77 Don't steal focus when an agent spawns a child via MCP 2026-05-15 15:53:50 +01:00
24c8183832 Auto-snap child viewport to bottom when typing into scrollback
Typing into a focused child while its emulator viewport was
scrolled up left the keystroke heading to the PTY but the input
box invisible below the visible region — it looked like typing
did nothing. processStdin's flushForward now sets
pendingViewportBottom whenever bytes are actually injected, so
the existing post-loop handler snaps the viewport and repaints.

Wheel events and Ctrl-B paths are untouched: both are intercepted
before reaching forward, so wheel still scrolls into history and
Ctrl-B is still the explicit escape hatch. Only bytes that would
actually reach the child PTY trigger the snap.
2026-05-15 15:34:00 +01:00
b5dfaf39c4 Marquee long sidebar names; truncate with ellipsis otherwise
Sidebar rows that overflow the rail width used to spill characters
into the main viewport. They now truncate with a trailing "…"
when unfocused (or when the focused name still fits). The focused
row whose name overflows runs a pause-scroll-pause marquee: 1 s
hold on the head, ~150 ms per cell scroll, 1 s hold on the tail,
snap back. The row's geometry never moves while it animates, so
nothing below shifts.

A dedicated 150 ms goroutine flips sidebarDirty only while a row
is actively animating; the chrome ticker does the actual repaint.
Idle is a single cheap wakeup. focus / spawn / exit / restart all
reset the marquee state so the new focused row starts from frame
zero. When the row's budget is tight, the trailing timer
indicator drops before the name ellipses since the name is the
only identifier the row carries.

clampVisible() is a defensive net inside write(): even if a row's
decoration size were mis-computed, it will not spill past the
sidebar band into the PTY area.
2026-05-15 15:33:39 +01:00
1fb919c22a Keep parent tab highlighted when focus is on a sub-agent
The top tab bar compared against focusedID, so stepping into a
sub-agent dropped the parent tab's highlight even though the user
was still inside that thread. activeAgentID already walks the
parent chain to the top-level root for the sidebar's agent tree
— reuse it for the tab strip too.
2026-05-15 15:26:06 +01:00
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
99 changed files with 15001 additions and 816 deletions

View File

@@ -11,14 +11,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: jdx/mise-action@v2
with:
go-version-file: go.mod
cache: true
- uses: mlugg/setup-zig@v1
- name: Cache Go modules
uses: actions/cache@v4
with:
version: 0.15.2
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Build libghostty-vt
run: make deps

1
.gitignore vendored
View File

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

10
.mise.toml Normal file
View File

@@ -0,0 +1,10 @@
# 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. The go pin matches go.mod so CI and local
# builds use the same toolchain.
[tools]
zig = "0.15.2"
go = "1.26.3"

View File

@@ -7,6 +7,397 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `patterm daemon`, `patterm daemon stop`, and `patterm ls` now expose
a local unix-socket daemon lifecycle for the daemon/client split.
- The local daemon protocol now supports attach, explicit detach,
project listing, focused-pane snapshots, pane chunks, resize/focus
updates, and daemon-owned command spawn requests while keeping child
processes alive after a client disconnects.
- The default `patterm [dir]` startup now auto-starts the local daemon
on demand and attaches a thin terminal client over the unix-socket
transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy
single-process path available as an escape hatch.
- `patterm daemon --listen HOST:PORT` can now opt into a TCP listener
for remote human clients, with the unix socket still enabled for
local clients.
- `patterm connect --host HOST:PORT [--token TOKEN]` attaches the thin
client to a remote daemon over the same transport protocol.
- TCP attaches now require a lightweight bearer token stored under
`$XDG_DATA_HOME/patterm/clients/token`; local unix-socket attaches
remain exempt and rely on socket file permissions.
- The daemon now tracks a display owner per pane so a second client
viewing the same pane does not resize the underlying PTY/emulator;
ownership is released on detach and the next focuser can claim and
resize the pane.
- patterm can now keep multiple local projects loaded in one loopback
daemon core, with command-palette entries to switch the current
client view or open another project without tearing down processes
in the previous project.
- The status line now shows the current project name when multiple
projects are loaded, and the MCP startup greeting includes
`project_key` for diagnostics and future daemon routing.
- MCP clients can now call `scratchpad_delete` with a scratchpad name
to remove a shared project scratchpad.
### Changed
- The tab bar now shows each visible agent tab's own summary instead
of only rendering the focused tab's summary.
- Grid-mode `get_process_output` now returns whitespace-normalized
text to avoid sending padded terminal rows and repeated blank lines
over MCP.
### Fixed
- MCP scratchpad tools now route through the caller's project instead
of always using the daemon registry's default project.
- Injected agent input now sends the submit Enter as a separated,
settled keystroke so messages reliably submit instead of sometimes
sitting unsent in the composer.
- Codex agents are no longer reported idle while a turn is still
running.
- Slow MCP tool calls such as `wait_for_pattern` no longer block later
tool calls on the same MCP connection.
- Closing an agent now escalates from SIGTERM to SIGKILL when needed,
so agents that ignore SIGTERM disappear from the running tab bar
after one Close action while keeping their exited pane readable.
- Sidebar timer indicators now repaint as their visible countdown
value changes, so labels progress from minutes to seconds without
waiting for unrelated terminal output or focus changes.
- Raw terminal focused actions now show a single `Close` row instead
of separate stop/delete-style lifecycle choices that did the same
thing for ephemeral terminal panes.
- Restarting a process from the palette now restores the focused pane
and host chrome before waiting for the old process to exit, so the
tab bar and sidebar do not disappear during slow restarts.
- Deleting the focused scratchpad now moves focus to another
scratchpad when one exists, or back to a running terminal/agent
instead of dropping into the empty state.
- Multiline paste into raw terminal and command panes no longer pays
the agent-specific per-Enter delay, making large pasted input arrive
as one PTY write outside Claude/Codex/OpenCode panes.
## [0.0.7] - 2026-05-18
### Added
- The top tab bar now prefixes each agent tab's label with its
idle-state glyph (✕ error, ? permission, ◐ thinking, ○ idle, ●
working), matching the sidebar's vocabulary so the state of every
open agent is visible without opening or focusing each tab.
### Changed
- Built-in agent presets (`claude`, `codex`, `opencode`) now live in
memory and user preset files merge over them by name instead of
patterm writing default preset files into `$XDG_CONFIG_HOME`. Add
`"disabled": true` in a matching user preset to hide a built-in.
- Generated MCP config files for agent launches now live under the
runtime agent directory instead of `$XDG_CONFIG_HOME/patterm/mcp`.
- Auto-summarization settings now save as soon as a changed row is
applied, including cadence/provider/toggle changes and model edits,
without requiring a separate save step.
- The Agents / Auto-summarization settings screen no longer shows
explicit Save, Cancel, or Back rows, and its footer copy no longer
describes a separate save/cancel flow.
- Auto-summarization setting rows now visually separate grey labels
from regular-colour values.
- The active-thread summary in the tab bar is now constrained to the
active tab's width instead of spanning the whole top row.
- Sidebar summary text now wraps from the full summary text instead of
using an ellipsized single-line value.
### Fixed
- Claude permission prompts are now detected from the rendered pane as
well as the recent output tail, so the sidebar marks the pane as
waiting for permission even while `Calling patterm...` continues to
repaint.
- Removed the redundant "Back to Settings" row from the
Agents / Auto-summarization settings screen.
- Pending `timer_*` entries are now cancelled when their owning or
watched child is closed via `close_process`, preventing stale
timer bodies from being re-delivered to the orchestrator pane
after the work has already been handled.
## [0.0.6] - 2026-05-15
### Changed
- Toast notifications now reserve three content rows and word-wrap
the message body inside the box, replacing the previous
single-line+ellipsis layout. The `Ctrl-N · N more` inline hint is
gone; instead the host status strip surfaces a `Ctrl-N · dismiss`
hint, shown only while a notification is on screen so the chord
doesn't advertise itself when it has nothing to dismiss.
### Fixed
- Auto-summary no longer fails immediately with `codex summarizer:
error: unexpected argument '--ask-for-approval' found`. The codex
CLI dropped that flag; we now rely on `--sandbox read-only` (which
already implies no approvals) instead of passing it.
- Toast box no longer flickers / half-erases while the focused
child (claude, codex, opencode, etc.) repaints its TUI. The
overlay is now stitched onto the end of the per-chunk PTY write
under `outMu`, and wrapped in DECSET 2026 (synchronized output)
brackets so terminals that support it batch the child's redraw +
the box paint into a single frame instead of racing cell-by-cell.
## [0.0.5] - 2026-05-15
### Changed
- Replaced the single-slot status-line "flash" with a stackable toast
surface anchored at the top-right of the focused pane. `flashError`,
`flashTransient`, and MCP `request_human_attention` now push onto
the toast stack (cap 5, oldest drops). Toasts persist until
dismissed with `Ctrl-N`, or cleared via the new
"Clear notifications" palette command. The status line no longer
shows the `[!]` prefix.
- `Ctrl-N` is consumed by the host only when there is a toast to
dismiss; an empty stack lets `Ctrl-N` pass through to the focused
child so readline / nano / emacs / opencode keep their bindings.
- Command palette is calmer when something is focused. Focused-section
rows now read as bare verbs (`Rename`, `Close`, `Stop`, `Restart`,
`Delete`, `Edit`) instead of repeating the focused name (`Close
agent: codex`); the title bar's `on: codex` / `pad: notes.md`
carries the subject. Fuzzy queries still match the dropped context
through the row hint (e.g. typing `close codex` still finds the
Close row).
- Dashed `── Focused ──` / `── Open ──` / `── Spawn ──` section
banners are gone. Sections are separated by a single blank spacer
row, so the action labels themselves carry the visual weight.
- The Open section no longer lists a `Switch to <current>` row for
the pane you're already focused on.
## [0.0.4] - 2026-05-15
### Changed
- Release workflow (`.gitea/workflows/release.yml`) now provisions
Zig and Go through `jdx/mise-action@v2`, reading the versions from
`.mise.toml` (zig 0.15.2, go 1.26.3). Both toolchains were
previously installed via `mlugg/setup-zig` and `actions/setup-go`,
whose mirror chase / GitHub fetch combined for ~8 minutes per run
before any patterm code compiled. mise pulls each tool once and
caches the install dir, so subsequent runs hit the cache instead of
re-downloading. `make deps` still resolves zig via `mise which zig`
with a PATH fallback; `go.mod` already pinned `go 1.26.3`, so the
new `go` entry in `.mise.toml` just keeps CI and local builds on
the same toolchain.
- A Go module/build cache step (`actions/cache@v4`, keyed on
`go.sum`) was added so `go build` doesn't re-download dependencies
on every tag push.
## [0.0.3] - 2026-05-15
### Added
- Auto-summarization for top-level agent tabs. patterm now loads
`$XDG_CONFIG_HOME/patterm/settings.json`, enables Codex-based
summaries by default (`gpt-5.4-mini`; OpenCode defaults to
`opencode-go/minimax-m2.7`), and can run Codex, OpenCode, or opt-in
Claude summarizers with configurable model names. Summary
attempts are armed by meaningful human input, wait for recent output
to go quiet, and respect a minimum cadence so unchanged tabs are not
summarized on a timer. The active thread summary appears under the
top tab title and in the sidebar below the Agent Tree section.
- Settings overlay reachable from the command palette via
`Open Settings`. The searchable Settings picker opens
`Agents / Auto-summarization`, where users can enable/disable
summaries, choose provider, edit provider model names, cycle cadence,
test the selected summarizer (`patterm okay`), summarize the current
top-level agent immediately, and explicitly save or cancel draft
settings changes. Cadence choices match Solo: `15s`, `30s`, and
`1m`; the value is a minimum quiet/activity gap before another
summary attempt for the same top-level agent, not a background
periodic timer.
### Changed
- Command palette UX overhaul. The single flat list grew section
bands (`── Focused ──`, `── Open ──`, `── Spawn ──`, `── Quit ──`)
so the rows are scannable at a glance; cursor navigation skips
the dim header rows transparently. A chip strip — `[All] Open
Spawn Close` — sits below the query line and tracks the active
macro filter; `Tab` / `Shift-Tab` cycle through the chips, and
the typed-prefix macros (`sw `, `sp `, `k `) still work and now
collapse the whole prefix on a single backspace instead of
leaving a stray `sw` behind. The title bar surfaces the current
focus subject (`on: <child>` / `pad: <name>`) so the user knows
which Focused row is targeting what. The duplicate global Close
list is gone — close is reachable via the Focused-section action,
the `k ` macro / `[Close]` chip, or the new `Ctrl-X` inline close
on a Switch row. The "(current)" marker on the focused Switch row
became a leading ``. The empty-state hint now reads `no matches
· ⌫ to widen` instead of bare `no matches`. The middle divider
shows a `▼ N more` / `▲ N above` scroll indicator when the list
overflows, and the footer carries a `cursor/total` counter.
- Spawn verbs are unified on **Spawn**: `Run process: …` →
`Spawn process: …`, `New Terminal` → `Spawn terminal`, and the
freeform-form row is now `Spawn process… (custom)` so the
trailing ellipsis still signals it opens a form.
- Filtering switched from binary fuzzy-include to scored ranking.
Prefix matches beat word-boundary matches beat substring matches
beat scattered-fuzzy matches; ties fall back to section order so
a Focused-section hit always outranks an equally tight Spawn
hit. The matched characters in the rendered label render in
accent+bold so the user can see why a row matched.
- Rename forms split the long subject (`scratchpad:
some-really-long-name.md`) onto its own dim row above the input
so the title bar no longer truncates with an ellipsis when the
subject name is wide.
- New palette accelerators: `Alt-1` … `Alt-9` quick-pick the Nth
visible row, `Home` / `End` jump to first / last selectable row,
`?` (with empty query) opens an inline keybinding cheat-sheet
which any further keystroke dismisses, and `Ctrl-R` inside the
Spawn-process form toggles "Relaunch on exit" without leaving
the command field.
### Fixed
- Error/status flashes now restore the currently focused pane instead
of drawing the empty-state hint over a running agent or process.
- Release workflow (`.gitea/workflows/release.yml`) now uses
`mlugg/setup-zig@v2` instead of the deprecated `@v1`. v1 hard-coded
the pre-0.14 tarball name (`zig-linux-x86_64-<ver>.tar.xz`), so
every mirror and the official `ziglang.org/builds` returned 404 for
Zig 0.15.2 and the v0.0.1 / v0.0.2 tag pushes never produced a
release asset. v2 uses the post-0.14 `zig-x86_64-linux-<ver>.tar.xz`
layout, so the runner can fetch Zig and build patterm.
- Typing into a focused child while its emulator viewport is
scrolled up into scrollback history now auto-snaps the viewport
back to the live area. Previously the keystroke reached the
child PTY but the input box was off-screen below the visible
region, so it looked like typing did nothing. Wheel scrolling
and Ctrl-B are unchanged; only forwarded keystrokes snap.
- Top tab bar now keeps the top-level agent's tab highlighted
when focus is on one of its sub-agents (or on a Processes pane
entry, matching the existing agent-tree behavior). Previously
the tab would lose its highlight as soon as you stepped into a
child agent, even though you were still within that thread.
### Changed
- MCP tool descriptions and `help('coordination')` /
`help('readiness')` now spell out that a sub-agent's reply to
`send_message` lands in the caller's own pane (tagged
`[sub-agent:<name>]`), not in the sub-agent's output. The canonical
wait-for-reply pattern — `send_message` → `timer_fire_when_idle_any`
on the sub-agent → read your own pane — is now called out on
`send_message`, `wait_for_pattern`, both `timer_fire_when_idle_*`,
the help topics, and the server-instructions preamble every agent
reads at startup. Previously `wait_for_pattern` was the obvious
blocking primitive in the catalog, and agents routinely called it
against the sub-agent for a reply that had already arrived in their
own pane, deadlocking until the wait timed out. No behaviour
changes; descriptions only.
- Agent-initiated `spawn_agent` and `spawn_process` MCP calls no
longer steal viewport focus from the currently active tab. The
new child still appears in the sidebar and tab bar; switch to it
explicitly via the palette or `select_process`. Palette-initiated
spawns and persistence restores are unchanged — they still auto-
focus the new pane.
- Sidebar rows (Processes, Agent Tree, Scratchpads) now truncate
overflowing names with a trailing `` instead of spilling into
the main viewport. The focused row marquees its name when it
overflows — 1 s hold on the head, ~150 ms per cell scroll until
the tail is visible, 1 s hold on the tail, snap back. Row
position never moves while the marquee animates. When budget is
tight, the trailing timer indicator drops before the name
ellipses, since the name is the only identifier the row carries.
## [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 +455,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 +470,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)"

23
SPEC.md
View File

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

20
TODO.md
View File

@@ -1,20 +0,0 @@
- [ ] 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.
# On Hold
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD]
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
most likely the *host* terminal's font fallback for opencode's
Nerd Font private-use codepoints, not a patterm substitution.
Need a concrete reproduction (which codepoint, which host
terminal/font) before changing rendering.
- [ ] After codex rips for like 15 minutes, the terminal becomes quite slow. [ON HOLD / VERIFYING]
- 2026-05-14: Perf plan P1-P11 landed (see CHANGELOG). Needs a real
long-running codex session to confirm whether the steady-state
slowdown is gone or some hotspot remains. Capture a pprof if it
still feels slow after ≥15 minutes — the structural drivers the
audit named are all addressed, so a remaining symptom is a new
one and probably wants fresh profiling.

View File

@@ -14,9 +14,14 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"runtime/pprof"
"time"
flag "github.com/spf13/pflag"
@@ -24,6 +29,7 @@ import (
"github.com/hjbdev/patterm/internal/app"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/projectkey"
"github.com/hjbdev/patterm/internal/protocol"
)
// version is overridden at build time via `-ldflags "-X main.version=..."`.
@@ -45,11 +51,32 @@ func main() {
runDebugHarness()
return
}
if len(os.Args) >= 2 && os.Args[1] == "daemon" {
os.Args = append(os.Args[:1], os.Args[2:]...)
runDaemonCommand()
return
}
if len(os.Args) >= 2 && os.Args[1] == "connect" {
os.Args = append(os.Args[:1], os.Args[2:]...)
runConnectCommand()
return
}
if len(os.Args) >= 2 && os.Args[1] == "ls" {
runDaemonList()
return
}
var (
projectDir = flag.String("project", "", "project directory (default $PWD)")
showVersion = flag.Bool("version", false, "print version and exit")
inProcess = flag.Bool("in-process", false, "run the legacy single-process TUI instead of attaching to the daemon")
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 {
@@ -63,6 +90,8 @@ func main() {
}
if *projectDir != "" {
cwd = *projectDir
} else if flag.NArg() > 0 {
cwd = flag.Arg(0)
}
key, err := projectkey.Key(cwd)
if err != nil {
@@ -73,15 +102,119 @@ 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{
if *inProcess || os.Getenv("PATTERM_NO_DAEMON") != "" {
if err := app.Run(ctx, app.Options{
ProjectDir: cwd,
ProjectKey: key,
DebugDir: resolvedDebug,
ProfileDir: resolvedProfile,
}); err != nil {
die("%v", err)
}
return
}
if resolvedDebug != "" || resolvedProfile != "" {
die("--debug and --profile currently require --in-process")
}
if err := app.RunAttachedClient(ctx, app.ClientOptions{
ProjectDir: cwd,
ProjectKey: key,
Stdin: os.Stdin,
Stdout: os.Stdout,
RawMode: true,
AutoStart: true,
}); 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")
@@ -96,6 +229,141 @@ func runMCPProxy() {
}
}
func runDaemonCommand() {
if len(os.Args) >= 2 && os.Args[1] == "stop" {
runDaemonStop()
return
}
if len(os.Args) >= 2 && os.Args[1] == "ls" {
runDaemonList()
return
}
var (
projectDir = flag.String("project", "", "initial project directory (default $PWD)")
listenAddr = flag.String("listen", "", "optional TCP listen address for remote human clients (for example 127.0.0.1:2488, 0.0.0.0:2488, or 2488)")
)
flag.Parse()
cwd, err := os.Getwd()
if err != nil {
die("getwd: %v", err)
}
if *projectDir != "" {
cwd = *projectDir
} else if flag.NArg() > 0 {
cwd = flag.Arg(0)
}
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd, ListenAddr: *listenAddr}); err != nil {
die("daemon: %v", err)
}
}
func runConnectCommand() {
var (
host = flag.String("host", "", "remote daemon host:port")
token = flag.String("token", "", "remote daemon token (default PATTERM_TOKEN or stored token file)")
projectDir = flag.String("project", "", "project directory to request on the daemon")
)
flag.Parse()
if *host == "" && flag.NArg() > 0 {
*host = flag.Arg(0)
}
if *host == "" {
die("connect: --host HOST:PORT is required")
}
tok := *token
if tok == "" {
tok = os.Getenv("PATTERM_TOKEN")
}
if tok == "" {
if stored, err := app.LoadClientToken(); err == nil {
tok = stored
}
}
if tok == "" {
die("connect: token required via --token, PATTERM_TOKEN, or %s", mustTokenPath())
}
cwd := *projectDir
if cwd == "" {
var err error
cwd, err = os.Getwd()
if err != nil {
die("getwd: %v", err)
}
}
tr, err := app.DialTCPTransport(*host)
if err != nil {
die("connect: %v", err)
}
defer tr.Close()
if err := app.RunAttachedClient(context.Background(), app.ClientOptions{
ProjectDir: cwd,
Transport: tr,
Stdin: os.Stdin,
Stdout: os.Stdout,
RawMode: true,
Token: tok,
}); err != nil {
die("connect: %v", err)
}
}
func mustTokenPath() string {
path, err := app.ClientTokenPath()
if err != nil {
return "$XDG_DATA_HOME/patterm/clients/token"
}
return path
}
func runDaemonList() {
projects, err := daemonRequest(protocol.Frame{Type: protocol.FrameList})
if err != nil {
die("ls: %v", err)
}
for _, p := range projects.Projects {
fmt.Printf("%s\t%d\t%s\n", p.Key, p.TabCount, p.Path)
}
}
func runDaemonStop() {
if _, err := daemonRequest(protocol.Frame{Type: protocol.FrameStop}); err != nil {
die("daemon stop: %v", err)
}
fmt.Println("stopped")
}
func daemonRequest(req protocol.Frame) (protocol.ProjectList, error) {
socket, _, err := app.RuntimeDaemonPaths()
if err != nil {
return protocol.ProjectList{}, err
}
conn, err := net.Dial("unix", socket)
if err != nil {
return protocol.ProjectList{}, err
}
defer conn.Close()
t := protocol.NewConnTransport(conn)
if err := t.Send(req); err != nil {
return protocol.ProjectList{}, err
}
resp, err := t.Recv()
if err != nil {
return protocol.ProjectList{}, err
}
if resp.Type == protocol.FrameError {
var msg protocol.Error
_ = json.Unmarshal(resp.Payload, &msg)
if msg.Message == "" {
msg.Message = "daemon returned an error"
}
return protocol.ProjectList{}, fmt.Errorf("%s", msg.Message)
}
if resp.Type != protocol.FrameProjectList {
return protocol.ProjectList{}, fmt.Errorf("unexpected daemon response %q", resp.Type)
}
return protocol.Decode[protocol.ProjectList](resp)
}
func versionString() string {
commit, date := "unknown", "unknown"
if info, ok := debug.ReadBuildInfo(); ok {

View File

@@ -108,7 +108,7 @@ func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthro
}
defer em.Close()
child, err := pty.Start(argv, nil, cols, rows)
child, err := pty.Start(argv, nil, "", cols, rows)
if err != nil {
return fmt.Errorf("pty: %w", err)
}

273
docs/daemon-client-plan.md Normal file
View File

@@ -0,0 +1,273 @@
# patterm: persistent daemon + thin networked client — implementation plan
Status: implemented — Phases 04 landed on this branch. Branch: `feat/daemon-client-split`.
> Implemented: pty workdir/process-group + protocol/Transport/loopback foundation;
> multi-project `ProjectRegistry`; out-of-process unix-socket daemon with auto-start,
> `daemon stop`/`ls`, detach (Ctrl-]) + reconnect; opt-in LAN TCP listener with a
> lightweight bearer token + `patterm connect`; per-pane display-owner sizing for
> multi-client viewing. Deferred (not built): TLS (transport kept pluggable),
> remote MCP, durable restore of live PTYs across daemon restart.
## Goal
Turn patterm from a single foreground process into a persistent background
**daemon** that owns all process/project state, plus a thin **client** that
renders and forwards input. A client on another LAN device can attach,
navigate projects via the command palette, detach, and reconnect — with child
processes surviving across client disconnects.
## Locked decisions
1. **Scope:** build all phases; land as one PR off this branch.
2. **Remote access:** human UI clients only. MCP for agents stays local
(per-daemon unix socket); no remote MCP transport in this work.
3. **Multi-client = per-client independent view.** The daemon holds pure
process/project state. Each client connection owns a `ClientView`
(selected project, focused pane/pad, scroll offset, palette state,
terminal size). Two clients may sit on different projects at once.
4. **Daemon lifecycle:** auto-start on demand (tmux/docker model). `patterm`
starts the daemon if absent and attaches; `patterm daemon stop|ls` manage it.
5. **Durability:** "persistent" = survive client disconnect while the daemon
process lives. Daemon restart only rehydrates today's persist model
(top-level commands, fresh IDs). No attempt to resurrect live PTYs/agents
after daemon death.
6. **Auth (trusted-network stance):** Harry runs this on a trusted LAN and is
fine with LAN exposure. Keep it lightweight: localhost default, opt-in LAN
bind (`--listen`), a simple pairing/bearer token to prevent accidental
drive-by access. TLS/cert-pinning is NOT required now but the transport must
stay pluggable so TLS can be layered in later.
7. **Detach gesture:** explicit detach via a palette command and/or a dedicated
host chord. Ctrl-D stays as PTY input (shell EOF), as today. Quit-project and
stop-daemon are explicit actions.
## Current architecture (baseline facts — verify before editing)
- `app.Run` (`internal/app/app.go:49`) wires the entire process: presets,
settings, scratchpad/trust/persist stores, in-process MCP server, ONE
`Session`, the `uiState` TUI, classifier, SIGWINCH, 60Hz chrome ticker,
blocking `stdinLoop`.
- **The seam:** `ChildEventListener` (`internal/app/session.go:83`) —
`OnChildSpawned`/`OnChildExited`/`OnPTYOut`/`OnChildStateChanged`/
`OnChildClosed`. Today `uiState` is the only real listener (subscribed at
`app.go:198`). A remote client = a serialized listener + reverse command
channel.
- One `Session` (`session.go:28`) holds a flat `children map[string]*Child` +
`order`. Tabs are derived: `KindAgent` children with `ParentID==""`
(`tree.go` `runningTopLevels`). The whole tree is reconstructed from
`Child.ParentID`.
- `Child` (`child.go:72`) owns `*pty.PTY`, `*vt.GhosttyEmulator`, raw ring,
status/owner atomics. Lifecycle: `Session.Spawn` (`session.go:222`) →
`startPTY``pumpChild` (`session.go:423`, PTY→emulator→ring→`emitPTYOut`)
+ `reapChild` (`session.go:488`, exit→`killDescendantsOf`).
- Stores already keyed by projectKey on `Open`
(`scratchpad`/`trust`/`persist`); `projectkey.Key(dir)` =
`sha256(realpath)[:16]`.
- `SerializeChild` (`session.go:687`) already yields a full VT snapshot for
stateless repaint.
- Rendering writes ANSI to `os.Stdout` under `outMu`; `viewportRenderer`
(`internal/app/viewport_renderer.go`) is a stateful ANSI rewriter confining
child output to the viewport. Input: raw `os.Stdin` via `stdinLoop`
(`app.go:1433`)/`processStdin`.
- MCP: in-process `Server` (`internal/mcp/mcp.go:26`), newline-JSON over a
per-PID unix socket `$XDG_RUNTIME_DIR/patterm/<pid>.sock`. Agents launch
`patterm mcp-stdio --socket S --identity T`. Identity → `callerID` via
`host.ResolveCallerIdentity``Session.FindChildByIdentity`.
- **No TCP/TLS anywhere today.** All `net.Listen`/`net.Dial` are unix sockets.
- **Must-fix:** `pty.Start` (`internal/pty/pty.go:26`) does not set `cmd.Dir`;
today the process `os.Chdir`s once. A daemon can't chdir globally, so
`SpawnSpec.WorkDir` must propagate to `exec.Cmd.Dir`.
## Target component model
| Component | Owns |
|---|---|
| `internal/daemon` (`pattermd`) | Project registry (N `Session`s), all PTYs, emulators, MCP server, per-project stores, classifier, timers. No TTY. |
| `internal/client` (`patterm`) | Real terminal: raw mode, alt-screen, SIGWINCH, stdin/stdout; `uiState`, `viewportRenderer`, chrome draws, palette, input. Holds `ClientView`. |
| `internal/transport` | `Transport` interface + framing; loopback, unix, TCP/TLS impls; auth handshake. |
| `internal/protocol` | Wire message types shared by daemon + client. |
### `Transport` interface (migration linchpin)
```go
type Transport interface {
Send(Frame) error // client→daemon command, or daemon→client push
Recv() (Frame, error)
Close() error
}
```
- **Loopback impl:** in-process channels, zero serialization. Default
`patterm` = client + loopback daemon in one process → today's UX preserved
exactly, single binary.
- **Net impl:** framed JSON-per-line over `net.Conn`, reusing the
`mcp.go:handleConn` pattern; unix socket first, then TCP/TLS.
### Per-client state vs daemon state
```go
// daemon-side, pure process/project state
type Registry struct { projects map[string]*Project } // key = projectKey
type Project struct {
Key, Dir, Name string
Session *Session
Pads *scratchpad.Store
Trust *trust.Store
Persist *persist.Store
Launcher *Launcher
Host *ToolHost
}
// per-connection, client-owned view state (lives client-side; daemon tracks
// only what it must to size emulators + route subscriptions)
type ClientView struct {
ID string
ProjectKey string // which project this client is looking at
FocusedID string // pane (Child) or pad
ScrollOff int
Cols, Rows uint16
// palette state is fully client-local
}
```
Project switch = re-point this client's subscription to another `Project`'s
Session + send `chrome` + `pane_snapshot`. No process teardown.
### Wire protocol (control + UI channel)
Bidirectional framed JSON-per-line.
Daemon → client:
- `hello` / `auth_challenge` / `auth_ok` — handshake.
- `project_list``[{key, path, name, last_active, tab_count}]` for the
palette switcher.
- `chrome` — semantic model for the client's current project+view: tab list
(`runningTopLevels`), sidebar tree (`sidebarNav`), status/owner, toasts,
scratchpad list + selected preview. Client draws chrome locally
(reuses `tabbar.go`/`sidebar.go`).
- `pane_snapshot{paneID, vtBytes}` — full repaint on focus/attach/switch via
`SerializeChild`.
- `pane_chunk{paneID, bytes}` — live focused-pane PTY output (serialized
`OnPTYOut`).
- `lifecycle{spawned|exited|closed|stateChanged,...}` — serialized listener.
- `attention` / `trust_prompt` — human-facing surfaces; render on the client
whose view owns the relevant project.
Client → daemon:
- `attach{token, term_size, project_key?}` / `detach`.
- `input{paneID, bytes}` (the `InjectAsUser` path).
- `focus{paneID|pad}`, `switch_project{key}`, `open_project{path}`.
- `palette_command{...}` (spawn/kill/rename/quit-project), `trust_response`,
`resize{cols,rows}`.
**Encoding decision:** ship raw focused-pane PTY bytes + periodic
`SerializeChild` snapshots; client runs its own `viewportRenderer`. No
daemon-side pre-render (keeps daemon size-agnostic), no grid diffs in v1.
Requires in-order delivery only (TCP gives it). Diffs are a later optimization.
### Emulator sizing with per-client views
Each `Child` emulator has one size. Rules:
- A pane is sized by the client(s) viewing it. If exactly one client focuses a
pane, that client's cols/rows drive `ResizeAll` for that pane.
- If two clients focus the **same** pane, one is the **display owner** (first
to focus, or explicit take-control); the owner's size drives the emulator;
the other letterboxes/clips. Surface a toast.
- Because clients are usually on different projects/panes, contention is rare.
### Security (human clients, LAN — trusted-network stance)
Harry runs this on a trusted LAN (decision #6). Keep it lightweight but not
wide open:
- localhost-only by default. LAN bind (`--listen 0.0.0.0:PORT`) is explicit
opt-in, never default.
- A simple pairing/bearer token gates network attach so a stray host on the LAN
can't drive-by-attach. Daemon prints the token on `--listen`; client presents
it in `attach`; store a per-client token after first pairing.
- Local unix-socket clients keep `0600` perms (sufficient for same-user).
- Keep the transport pluggable so TLS + cert pinning can be layered in later
without reworking the protocol. Not building TLS now.
- Trust prompts may now be approved from another device — deliberate; route to
the client whose view owns the project.
### Daemon lifecycle (auto-start)
- Well-known local socket `$XDG_RUNTIME_DIR/patterm/daemon.sock` +
pidfile/lockfile (single daemon per user).
- `patterm [dir]`: dial the socket; if absent, fork-exec the daemon, wait for
readiness, attach. `--project`/dir selects the initial project for the view.
- `patterm daemon` (foreground), `patterm daemon stop`, `patterm ls`.
- **Detach = explicit** palette command and/or a dedicated host chord; PTYs keep
running. Ctrl-D stays as PTY input (shell EOF). Quitting a project / killing
the daemon are explicit palette/CLI actions.
- Idle-shutdown policy: configurable; default keep alive until explicit stop.
## Package-by-package changes
- **`cmd/patterm`** (`main.go`): add `daemon` subcommand (headless core);
default invocation becomes client (auto-start/attach); `mcp-stdio` dials the
shared daemon socket (not per-PID); `debug-harness` drives a daemon (or
loopback).
- **`internal/app` split:**
- new **`internal/daemon`**: headless half — move `session.go`, `child.go`,
`host.go`, `tree.go`, `launch.go`, classifier, timers, `Shutdown`,
kill-cascade. Add `Registry`/`Project`.
- **`internal/client`**: TTY half — `uiState`, `viewport_renderer.go`,
`screen_renderer.go`, `tabbar.go`, `sidebar.go`, status, `palette.go`,
`stdinLoop`/`processStdin`, SIGWINCH/chrome ticker, markdown/marquee/toast.
Consumes events + chrome over `Transport` instead of `sess.Subscribe`.
- **new `internal/transport` + `internal/protocol`**: messages, framing,
loopback/unix/TCP-TLS impls, auth handshake.
- **`internal/mcp`**: `SocketPath` per-daemon (not per-PID);
`ResolveCallerIdentity` becomes daemon-wide across projects (token already
carries `PATTERM_PROJECT_KEY` via `ChildEnv`).
- **`internal/pty`**: set `cmd.Dir` from `SpawnSpec.WorkDir`; add process-group
handling for reliable tree teardown.
- **`internal/vt`**: unchanged grid source of truth; enforce per-child
serialization around emulator access (interface isn't concurrency-safe) since
clients + MCP + pump all snapshot.
- **`internal/{scratchpad,trust,persist}`**: per-`Project` instances in the
registry (already keyed by projectKey).
- **`internal/preset`**: project-agnostic; daemon loads once, shares.
- **`internal/projectkey`**: doc update (key is now load-bearing for routing).
- **`internal/harness`**: add daemon/loopback mode; assert child survives client
disconnect/reconnect, project-switch preserves each project's tree, two
clients on different projects, unauth TCP rejected.
## Backpressure
`pumpChild`'s listener calls are synchronous (`session.go:149`). A slow network
client must not block the PTY pump. Introduce a per-client event bus with a
bounded buffer that coalesces/ drops to a snapshot under pressure, decoupled
from `pumpChild`.
## Phased roadmap (all phases land on this branch)
0. **Extract headless core behind loopback transport.** `daemon.Core` +
`client` over in-process `Transport`. Zero behavior change; harness green.
1. **Multi-project registry + per-client view scaffolding.** Registry, per-
project stores, `ClientView`, palette "Switch/Open project…", project tier
in chrome. Still single local process.
2. **Out-of-process daemon over unix socket.** Auto-start/attach; PTYs survive
client exit; reconnect + snapshot-on-attach; Ctrl-D = detach; pidfile/lock.
3. **TCP + TLS + auth.** localhost TCP, then opt-in LAN bind; pairing token /
cert pinning; remote trust-prompt routing.
4. **Per-client view fully realized + emulator sizing/display-owner.**
Independent focus/scroll/palette per client; multi-client on same/different
projects; resize negotiation + letterbox.
5. **Hardening.** systemd/launchd autostart, `daemon stop|ls`, idle-shutdown,
backpressure, security review, CHANGELOG.
## Risks / open questions for review
- Heterogeneous client sizes vs one-PTY-one-size (display-owner + letterbox is
the v1 answer — is it sufficient?).
- Security escalation: a network client spawns processes / runs shell / injects
input. Auth/TLS scope adequate?
- Ctrl-D semantics flip — acceptable UX?
- Backpressure design — bounded bus + snapshot-on-pressure correct?
- MCP identity uniqueness across projects after per-PID socket removal.
- Is per-client view (decision #3) worth doing from Phase 1, or staged after a
shared-focus interim that's faster to ship?
- Splitting `uiState` (focus/palette/render caches/trust prompt/dims/outMu) out
of the daemon is the largest refactor — sequencing concerns?

View File

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

@@ -0,0 +1,80 @@
package app
import "github.com/hjbdev/patterm/internal/scratchpad"
// chromeModel is the semantic host chrome state. Renderers continue to own
// ANSI output; this model is the serializable shape a client can draw locally.
type chromeModel struct {
ProjectKey string `json:"project_key"`
ProjectName string `json:"project_name,omitempty"`
FocusedID string `json:"focused_id,omitempty"`
FocusedPad string `json:"focused_pad,omitempty"`
ActiveAgentID string `json:"active_agent_id,omitempty"`
Tabs []childModel `json:"tabs"`
Processes []childModel `json:"processes"`
AgentTree []childModel `json:"agent_tree"`
Sidebar []navEntryModel `json:"sidebar"`
Scratchpads []scratchpadModel `json:"scratchpads"`
}
type childModel struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
ParentID string `json:"parent_id,omitempty"`
Status string `json:"status"`
Owner string `json:"owner"`
}
type navEntryModel struct {
ChildID string `json:"child_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type scratchpadModel struct {
Name string `json:"name"`
}
func buildChromeModel(projectKey string, view ClientView, children []*Child, pads []scratchpad.Entry) chromeModel {
active := view.ActiveAgentID
if active == "" {
active = activeRootID(children, view.FocusedID)
}
model := chromeModel{
ProjectKey: projectKey,
ProjectName: view.ProjectName,
FocusedID: view.FocusedID,
FocusedPad: view.FocusedPad,
ActiveAgentID: active,
}
for _, c := range runningTopLevels(children) {
model.Tabs = append(model.Tabs, serializeChildModel(c))
}
for _, c := range processList(children) {
model.Processes = append(model.Processes, serializeChildModel(c))
}
for _, c := range visibleAgentTree(children, active) {
model.AgentTree = append(model.AgentTree, serializeChildModel(c))
}
for _, n := range sidebarNav(children, active, pads) {
model.Sidebar = append(model.Sidebar, navEntryModel{ChildID: n.childID, Pad: n.pad})
}
for _, p := range pads {
model.Scratchpads = append(model.Scratchpads, scratchpadModel{Name: p.Name})
}
return model
}
func serializeChildModel(c *Child) childModel {
if c == nil {
return childModel{}
}
return childModel{
ID: c.ID,
Name: c.DisplayName(),
Kind: string(c.Kind),
ParentID: c.ParentID,
Status: string(c.Status()),
Owner: string(c.Owner()),
}
}

View File

@@ -0,0 +1,24 @@
package app
import "testing"
func TestBuildChromeModelSeparatesProcessesTabsAndSidebar(t *testing.T) {
running := StatusRunning
proc := testProcess("p1", "server", running)
agent := testAgent("a1", "codex", "", running)
sub := testAgent("a2", "worker", "a1", running)
model := buildChromeModel("project", ClientView{FocusedID: "p1", ActiveAgentID: "a1"}, []*Child{proc, agent, sub}, nil)
if len(model.Tabs) != 1 || model.Tabs[0].ID != "a1" {
t.Fatalf("tabs = %#v, want only top-level agent", model.Tabs)
}
if len(model.Processes) != 1 || model.Processes[0].ID != "p1" {
t.Fatalf("processes = %#v, want process section", model.Processes)
}
if len(model.AgentTree) != 2 || model.AgentTree[0].ID != "a1" || model.AgentTree[1].ID != "a2" {
t.Fatalf("agent tree = %#v", model.AgentTree)
}
if len(model.Sidebar) != 3 || model.Sidebar[0].ChildID != "p1" || model.Sidebar[1].ChildID != "a1" {
t.Fatalf("sidebar = %#v", model.Sidebar)
}
}

102
internal/app/classifier.go Normal file
View File

@@ -0,0 +1,102 @@
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 := stripANSIBytes(nil, c.tailBytes(classifierTailBytes))
var screen []byte
if em := c.Emulator(); em != nil {
if txt, err := em.ScreenText(); err == nil {
screen = []byte(txt)
}
}
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail, screen)
if c.setIdleState(state, reason) {
s.emitStateChanged(c.ID, state)
}
}
// 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
}

677
internal/app/client_net.go Normal file
View File

@@ -0,0 +1,677 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
cpty "github.com/creack/pty"
"golang.org/x/term"
"github.com/hjbdev/patterm/internal/protocol"
)
const (
clientKeyCtrlK byte = 0x0b
clientKeyCtrlBracket byte = 0x1d
)
type ClientOptions struct {
ProjectDir string
Transport protocol.Transport
Stdin io.Reader
Stdout io.Writer
RawMode bool
AutoStart bool
Token string
Cols uint16
Rows uint16
}
func RunAttachedClient(ctx context.Context, opts ClientOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return err
}
opts.ProjectDir = cwd
}
if opts.Stdin == nil {
opts.Stdin = os.Stdin
}
if opts.Stdout == nil {
opts.Stdout = os.Stdout
}
if opts.Transport == nil {
t, err := dialDaemonTransport(opts.ProjectDir, opts.AutoStart)
if err != nil {
return err
}
opts.Transport = t
defer t.Close()
}
if opts.Cols == 0 || opts.Rows == 0 {
opts.Cols, opts.Rows = clientHostSize(opts.Stdin)
}
c := newNetClient(opts)
return c.run(ctx)
}
func DialTCPTransport(addr string) (protocol.Transport, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, err
}
return protocol.NewConnTransport(conn), nil
}
func dialDaemonTransport(projectDir string, autoStart bool) (protocol.Transport, error) {
socket, _, err := RuntimeDaemonPaths()
if err != nil {
return nil, err
}
conn, err := net.Dial("unix", socket)
if err == nil {
return protocol.NewConnTransport(conn), nil
}
if !autoStart {
return nil, err
}
if err := startDaemonProcess(projectDir); err != nil {
return nil, err
}
deadline := time.Now().Add(5 * time.Second)
var last error
for time.Now().Before(deadline) {
conn, err = net.Dial("unix", socket)
if err == nil {
return protocol.NewConnTransport(conn), nil
}
last = err
time.Sleep(50 * time.Millisecond)
}
return nil, fmt.Errorf("daemon did not become ready: %w", last)
}
func startDaemonProcess(projectDir string) error {
exe, err := os.Executable()
if err != nil {
return err
}
cmd := exec.Command(exe, "daemon", "--project", projectDir)
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err == nil {
defer devNull.Close()
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
}
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
type netClient struct {
t protocol.Transport
in io.Reader
out io.Writer
raw bool
projectDir string
token string
layout terminalLayout
mu sync.Mutex
focusedID string
paneSize protocol.Size
ownerView bool
chrome chromeModel
renderer *viewportRenderer
palette *clientCommandPrompt
}
type clientCommandPrompt struct {
buf []byte
}
func newNetClient(opts ClientOptions) *netClient {
layout := newTerminalLayout(opts.Cols, opts.Rows)
return &netClient{
t: opts.Transport,
in: opts.Stdin,
out: opts.Stdout,
raw: opts.RawMode,
projectDir: opts.ProjectDir,
token: opts.Token,
layout: layout,
renderer: newViewportRenderer(layout),
}
}
func (c *netClient) run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var restore *term.State
if c.raw {
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
st, err := term.MakeRaw(int(f.Fd()))
if err != nil {
return err
}
restore = st
defer term.Restore(int(f.Fd()), restore)
}
}
c.enterScreen()
defer c.leaveScreen()
if err := c.sendAttach(); err != nil {
return err
}
errCh := make(chan error, 2)
go func() { errCh <- c.recvLoop(ctx, cancel) }()
go func() { errCh <- c.stdinLoop(ctx, cancel) }()
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
defer signal.Stop(winch)
go func() {
for {
select {
case <-ctx.Done():
return
case <-winch:
cols, rows := clientHostSize(c.in)
_ = c.resize(cols, rows)
c.enterScreen()
c.drawChrome()
}
}
}()
}
select {
case <-ctx.Done():
_ = c.t.Close()
return nil
case err := <-errCh:
cancel()
_ = c.t.Close()
if errors.Is(err, io.EOF) || errors.Is(err, protocol.ErrTransportClosed) {
return nil
}
return err
}
}
func (c *netClient) sendAttach() error {
f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{
ProjectPath: c.projectPath(),
Token: c.token,
TermSize: protocol.Size{
Cols: c.layout.childCols(),
Rows: c.layout.childRows(),
},
})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) projectPath() string {
return c.projectDir
}
func (c *netClient) recvLoop(ctx context.Context, cancel func()) error {
for {
select {
case <-ctx.Done():
return nil
default:
}
f, err := c.t.Recv()
if err != nil {
return err
}
if err := c.handleFrame(f); err != nil {
return err
}
if f.Type == protocol.FrameDetach {
cancel()
return nil
}
}
}
func (c *netClient) handleFrame(f protocol.Frame) error {
switch f.Type {
case protocol.FrameError:
msg, _ := protocol.Decode[protocol.Error](f)
if msg.Message == "" {
msg.Message = "daemon error"
}
return fmt.Errorf("%s", msg.Message)
case protocol.FrameHello:
return nil
case protocol.FrameProjectList:
return nil
case protocol.FrameChrome:
msg, err := protocol.Decode[protocol.Chrome](f)
if err != nil {
return err
}
var model chromeModel
if err := json.Unmarshal(msg.Model, &model); err != nil {
return err
}
c.mu.Lock()
c.chrome = model
if model.FocusedID != "" {
c.focusedID = model.FocusedID
}
c.mu.Unlock()
c.drawChrome()
case protocol.FramePaneSnapshot:
msg, err := protocol.Decode[protocol.PaneSnapshot](f)
if err != nil {
return err
}
c.mu.Lock()
c.focusedID = msg.PaneID
c.paneSize = msg.Size
c.ownerView = msg.DisplayOwner
c.renderer = newViewportRenderer(c.renderLayoutLocked(msg.Size))
renderer := c.renderer
c.mu.Unlock()
c.clearViewport()
c.drawChrome()
c.writeWrapped(renderer.Render(msg.Bytes))
case protocol.FramePaneChunk:
msg, err := protocol.Decode[protocol.PaneChunk](f)
if err != nil {
return err
}
c.mu.Lock()
focused := c.focusedID
renderer := c.renderer
c.paneSize = msg.Size
c.ownerView = msg.DisplayOwner
if renderer != nil && (msg.Size.Cols != 0 || msg.Size.Rows != 0) {
renderer.SetLayout(c.renderLayoutLocked(msg.Size))
}
c.mu.Unlock()
if msg.PaneID == focused && renderer != nil {
c.writeWrapped(renderer.Render(msg.Bytes))
}
case protocol.FrameLifecycle:
// The daemon follows lifecycle changes with chrome/snapshot updates
// when focus changes. Keep this as a wake point for future richer
// client-side state without blocking the frame stream.
return nil
}
return nil
}
func (c *netClient) stdinLoop(ctx context.Context, cancel func()) error {
buf := make([]byte, 4096)
for {
n, err := c.in.Read(buf)
if n > 0 {
if done, perr := c.processInput(buf[:n]); perr != nil || done {
cancel()
return perr
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
select {
case <-ctx.Done():
return nil
default:
}
}
}
func (c *netClient) processInput(chunk []byte) (bool, error) {
c.mu.Lock()
if c.palette != nil {
p := c.palette
c.mu.Unlock()
return c.processPaletteInput(p, chunk)
}
c.mu.Unlock()
forward := make([]byte, 0, len(chunk))
flush := func() error {
if len(forward) == 0 {
return nil
}
c.mu.Lock()
paneID := c.focusedID
c.mu.Unlock()
if paneID != "" {
f, err := protocol.NewFrame(protocol.FrameInput, protocol.Input{PaneID: paneID, Bytes: append([]byte(nil), forward...)})
if err != nil {
return err
}
if err := c.t.Send(f); err != nil {
return err
}
}
forward = forward[:0]
return nil
}
for _, b := range chunk {
switch b {
case clientKeyCtrlBracket:
if err := flush(); err != nil {
return false, err
}
return true, c.sendDetach()
case clientKeyCtrlK:
if err := flush(); err != nil {
return false, err
}
c.mu.Lock()
c.palette = &clientCommandPrompt{}
c.mu.Unlock()
c.drawPrompt()
case 0x17: // Ctrl-W: previous focus
if err := flush(); err != nil {
return false, err
}
_ = c.focusRelative(-1)
case 0x13: // Ctrl-S: next focus
if err := flush(); err != nil {
return false, err
}
_ = c.focusRelative(1)
default:
forward = append(forward, b)
}
}
return false, flush()
}
func (c *netClient) processPaletteInput(p *clientCommandPrompt, chunk []byte) (bool, error) {
for _, b := range chunk {
switch b {
case 0x1b: // ESC
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
c.drawChrome()
return false, nil
case 'd':
if len(p.buf) == 0 {
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
return true, c.sendDetach()
}
p.buf = append(p.buf, b)
case '\r', '\n':
command := strings.TrimSpace(string(p.buf))
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
if command == "" {
c.drawChrome()
return false, nil
}
return false, c.sendSpawnCommand(command)
case 0x7f, 0x08:
if len(p.buf) > 0 {
p.buf = p.buf[:len(p.buf)-1]
}
c.drawPrompt()
default:
if b >= 0x20 {
p.buf = append(p.buf, b)
c.drawPrompt()
}
}
}
return false, nil
}
func (c *netClient) sendDetach() error {
f, err := protocol.NewFrame(protocol.FrameDetach, protocol.Detach{})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) sendSpawnCommand(command string) error {
data, err := json.Marshal(map[string]any{
"argv": []string{command},
"name": command,
"shell": true,
})
if err != nil {
return err
}
f, err := protocol.NewFrame(protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) focusRelative(delta int) error {
c.mu.Lock()
model := c.chrome
current := c.focusedID
c.mu.Unlock()
ids := make([]string, 0, len(model.Processes)+len(model.AgentTree)+len(model.Tabs))
for _, n := range model.Sidebar {
if n.ChildID != "" {
ids = append(ids, n.ChildID)
}
}
if len(ids) == 0 {
for _, p := range model.Processes {
ids = append(ids, p.ID)
}
for _, p := range model.Tabs {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
idx := 0
for i, id := range ids {
if id == current {
idx = i
break
}
}
idx = (idx + delta + len(ids)) % len(ids)
f, err := protocol.NewFrame(protocol.FrameFocus, protocol.Focus{PaneID: ids[idx]})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) resize(cols, rows uint16) error {
c.mu.Lock()
c.layout = newTerminalLayout(cols, rows)
if c.renderer != nil {
c.renderer.SetLayout(c.renderLayoutLocked(c.paneSize))
}
size := protocol.Size{Cols: c.layout.childCols(), Rows: c.layout.childRows()}
c.mu.Unlock()
f, err := protocol.NewFrame(protocol.FrameResize, protocol.Resize{Size: size})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) renderLayoutLocked(size protocol.Size) terminalLayout {
l := c.layout
if size.Cols != 0 && size.Cols < l.mainCols {
l.mainCols = size.Cols
}
if size.Rows != 0 && size.Rows < l.mainRows {
l.mainRows = size.Rows
}
return l
}
func (c *netClient) enterScreen() {
_, _ = c.out.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h"))
c.installScrollRegion()
}
func (c *netClient) leaveScreen() {
_, _ = c.out.Write([]byte("\x1b[r\x1b[?6l\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"))
}
func (c *netClient) installScrollRegion() {
mainBottom := int(c.layout.statusRow) - statusRows
if mainBottom < int(c.layout.mainTop) {
return
}
fmt.Fprintf(c.out, "\x1b[?6l\x1b[%d;%dr\x1b[%d;%dH",
int(c.layout.mainTop), mainBottom,
int(c.layout.mainTop), int(c.layout.mainLeft))
}
func (c *netClient) clearViewport() {
for row := int(c.layout.mainTop); row < int(c.layout.statusRow); row++ {
fmt.Fprintf(c.out, "\x1b[%d;%dH\x1b[%dX", row, int(c.layout.mainLeft), int(c.layout.childCols()))
}
fmt.Fprintf(c.out, "\x1b[%d;%dH", int(c.layout.mainTop), int(c.layout.mainLeft))
}
func (c *netClient) writeWrapped(out []byte) {
if len(out) == 0 {
return
}
wrapped := make([]byte, 0, len(out)+10)
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
_, _ = c.out.Write(wrapped)
}
func (c *netClient) drawChrome() {
c.mu.Lock()
model := c.chrome
prompt := c.palette
c.mu.Unlock()
var b strings.Builder
width := int(c.layout.childCols())
fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX\x1b[2;1H\x1b[%dX\x1b[3;1H\x1b[%dX", width, width, width)
if len(model.Tabs) == 0 {
fmt.Fprintf(&b, "\x1b[1;2H%s+ new%s", styleDim, styleReset)
} else {
col := 1
for _, tab := range model.Tabs {
label := fitName(tab.Name, 18)
style := styleHint
if tab.ID == model.ActiveAgentID || tab.ID == model.FocusedID {
style = styleActive
}
fmt.Fprintf(&b, "\x1b[1;%dH%s %s %s", col, style, label, styleReset)
col += visibleLen(label) + 3
if col >= width {
break
}
}
}
fmt.Fprintf(&b, "\x1b[3;1H%s%s%s", styleBorder, strings.Repeat("─", width), styleReset)
if c.layout.sidebarVisible {
c.appendSidebar(&b, model)
}
status := "Ctrl-K command palette · Ctrl-] detach"
if model.FocusedID != "" {
status = fmt.Sprintf("%s · %s", model.FocusedID, status)
}
c.mu.Lock()
size := c.paneSize
ownerView := c.ownerView
c.mu.Unlock()
if model.FocusedID != "" && !ownerView && size.Cols != 0 && size.Rows != 0 {
status = fmt.Sprintf("viewing at owner size %dx%d · %s", size.Cols, size.Rows, status)
}
if prompt != nil {
status = "command: " + string(prompt.buf)
}
fmt.Fprintf(&b, "\x1b[%d;1H\x1b[7m%s%s", int(c.layout.statusRow), fitName(status, int(c.layout.hostCols)), styleReset)
_, _ = c.out.Write([]byte(b.String()))
}
func (c *netClient) appendSidebar(b *strings.Builder, model chromeModel) {
border := int(c.layout.sidebarLeft) - 1
for row := 1; row <= int(c.layout.statusRow)-1; row++ {
fmt.Fprintf(b, "\x1b[%d;%dH%s│%s", row, border, styleBorder, styleReset)
}
col := int(c.layout.sidebarLeft)
row := 1
write := func(text string) {
if row >= int(c.layout.statusRow) {
return
}
fmt.Fprintf(b, "\x1b[%d;%dH%-*s", row, col, int(c.layout.sidebarWidth)-1, fitName(text, int(c.layout.sidebarWidth)-1))
row++
}
write(styleActive + "Processes" + styleReset)
for _, p := range model.Processes {
prefix := " "
if p.ID == model.FocusedID {
prefix = "▎ "
}
write(prefix + p.Name)
}
row++
write(styleActive + "Agent Tree" + styleReset)
for _, p := range model.AgentTree {
prefix := " "
if p.ID == model.FocusedID {
prefix = "▎ "
}
write(prefix + p.Name)
}
row++
write(styleActive + "Scratchpads" + styleReset)
for _, p := range model.Scratchpads {
write(" " + p.Name)
}
}
func (c *netClient) drawPrompt() {
c.drawChrome()
}
func clientHostSize(r io.Reader) (cols, rows uint16) {
if f, ok := r.(*os.File); ok {
ws, err := cpty.GetsizeFull(f)
if err == nil && ws.Cols > 0 && ws.Rows > 0 {
return ws.Cols, ws.Rows
}
}
return 120, 40
}

View File

@@ -0,0 +1,157 @@
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"sync"
"testing"
"time"
"github.com/hjbdev/patterm/internal/protocol"
)
func TestNetClientFrameLoopSendsFocusedInput(t *testing.T) {
clientT, daemonT := protocol.NewLoopbackPair()
inR, inW := ioPipe(t)
out := &lockedBuffer{}
gotInput := make(chan protocol.Input, 1)
errCh := make(chan error, 1)
go func() {
f, err := daemonT.Recv()
if err != nil {
errCh <- err
return
}
if f.Type != protocol.FrameAttach {
t.Errorf("first frame = %s, want attach", f.Type)
errCh <- nil
return
}
sendTestFrame(t, daemonT, protocol.FrameHello, protocol.Hello{Version: 1, ClientID: "test", ProjectKey: "project"})
sendTestFrame(t, daemonT, protocol.FrameProjectList, protocol.ProjectList{})
model := chromeModel{
ProjectKey: "project",
FocusedID: "p1",
Processes: []childModel{{ID: "p1", Name: "shell", Kind: string(KindCommand), Status: string(StatusRunning)}},
Sidebar: []navEntryModel{{ChildID: "p1"}},
}
sendTestFrame(t, daemonT, protocol.FrameChrome, protocol.Chrome{ProjectKey: "project", Model: mustMarshalTest(t, model)})
sendTestFrame(t, daemonT, protocol.FramePaneSnapshot, protocol.PaneSnapshot{PaneID: "p1", Bytes: []byte("READY")})
for {
f, err := daemonT.Recv()
if err != nil {
errCh <- err
return
}
if f.Type != protocol.FrameInput {
continue
}
input, err := protocol.Decode[protocol.Input](f)
if err != nil {
errCh <- err
return
}
gotInput <- input
_ = daemonT.Close()
errCh <- nil
return
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runCh := make(chan error, 1)
go func() {
runCh <- RunAttachedClient(ctx, ClientOptions{
Transport: clientT,
Stdin: inR,
Stdout: out,
Cols: 80,
Rows: 24,
})
}()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) && !bytes.Contains(out.Bytes(), []byte("READY")) {
time.Sleep(10 * time.Millisecond)
}
if !bytes.Contains(out.Bytes(), []byte("READY")) {
t.Fatalf("snapshot was not rendered before input; output=%q", out.String())
}
if _, err := inW.Write([]byte("echo hi\r")); err != nil {
t.Fatalf("write stdin: %v", err)
}
select {
case input := <-gotInput:
if input.PaneID != "p1" || string(input.Bytes) != "echo hi\r" {
t.Fatalf("input = %#v", input)
}
case <-time.After(3 * time.Second):
t.Fatalf("client did not forward input")
}
cancel()
_ = inW.Close()
select {
case err := <-runCh:
if err != nil {
t.Fatalf("client run: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatalf("client did not stop")
}
if err := <-errCh; err != nil && err != protocol.ErrTransportClosed {
t.Fatalf("daemon side: %v", err)
}
}
type lockedBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.Write(p)
}
func (b *lockedBuffer) Bytes() []byte {
b.mu.Lock()
defer b.mu.Unlock()
return append([]byte(nil), b.b.Bytes()...)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.String()
}
func ioPipe(t *testing.T) (*io.PipeReader, *io.PipeWriter) {
t.Helper()
r, w := io.Pipe()
return r, w
}
func sendTestFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
t.Helper()
f, err := protocol.NewFrame(typ, payload)
if err != nil {
t.Fatalf("frame %s: %v", typ, err)
}
if err := tr.Send(f); err != nil {
t.Fatalf("send %s: %v", typ, err)
}
}
func mustMarshalTest(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}

View File

@@ -0,0 +1,135 @@
package app
import (
"encoding/json"
"sync"
"github.com/hjbdev/patterm/internal/protocol"
)
const defaultClientSubscriberQueue = 256
// clientSubscriber is the daemon-to-client event bridge. Unlike daemon-local
// listeners such as timers, debug capture, and waiters, it never blocks the PTY
// pump: PTY chunks are copied before enqueue, and overflow marks the pane as
// needing a fresh snapshot.
type clientSubscriber struct {
projectKey string
project *Project
clientID string
frames chan protocol.Frame
mu sync.Mutex
snapshotRequired map[string]bool
lifecycleDirty bool
}
func newClientSubscriber(project *Project, clientID string, size int) *clientSubscriber {
if size <= 0 {
size = defaultClientSubscriberQueue
}
projectKey := ""
if project != nil {
projectKey = project.Key
}
return &clientSubscriber{
projectKey: projectKey,
project: project,
clientID: clientID,
frames: make(chan protocol.Frame, size),
snapshotRequired: make(map[string]bool),
lifecycleDirty: false,
}
}
func (s *clientSubscriber) Recv() (protocol.Frame, bool) {
f, ok := <-s.frames
return f, ok
}
func (s *clientSubscriber) SnapshotRequired(childID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.snapshotRequired[childID]
}
func (s *clientSubscriber) OnChildSpawned(c *Child) {
s.sendLifecycle(protocol.LifecycleSpawned, c, "")
}
func (s *clientSubscriber) OnChildExited(c *Child) {
s.sendLifecycle(protocol.LifecycleExited, c, "")
}
func (s *clientSubscriber) OnChildClosed(id string) {
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: protocol.LifecycleClosed,
ProjectKey: s.projectKey,
ChildID: id,
})})
}
func (s *clientSubscriber) OnChildStateChanged(id string, state IdleState) {
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: protocol.LifecycleStateChanged,
ProjectKey: s.projectKey,
ChildID: id,
State: string(state),
})})
}
func (s *clientSubscriber) OnPTYOut(childID string, chunk []byte) {
cp := append([]byte(nil), chunk...)
var size protocol.Size
var ownerID string
if s.project != nil {
size, ownerID, _ = s.project.PaneDisplay(childID)
}
f, err := protocol.NewFrame(protocol.FramePaneChunk, protocol.PaneChunk{PaneID: childID, Bytes: cp, Size: size, DisplayOwner: ownerID == "" || ownerID == s.clientID})
if err != nil {
return
}
select {
case s.frames <- f:
default:
s.mu.Lock()
s.snapshotRequired[childID] = true
s.mu.Unlock()
}
}
func (s *clientSubscriber) sendLifecycle(kind protocol.LifecycleKind, c *Child, state string) {
var child json.RawMessage
if c != nil {
child = mustJSON(serializeChildModel(c))
}
childID := ""
if c != nil {
childID = c.ID
}
s.sendFrame(protocol.Frame{Type: protocol.FrameLifecycle, Payload: mustJSON(protocol.Lifecycle{
Kind: kind,
ProjectKey: s.projectKey,
ChildID: childID,
Child: child,
State: state,
})})
}
func (s *clientSubscriber) sendFrame(f protocol.Frame) {
select {
case s.frames <- f:
default:
s.mu.Lock()
s.lifecycleDirty = true
s.mu.Unlock()
}
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return nil
}
return b
}

View File

@@ -0,0 +1,32 @@
package app
import (
"testing"
"github.com/hjbdev/patterm/internal/protocol"
)
func TestClientSubscriberCopiesChunksAndMarksSnapshotOnOverflow(t *testing.T) {
sub := newClientSubscriber(&Project{Key: "project"}, "client", 1)
chunk := []byte("first")
sub.OnPTYOut("p_123456", chunk)
chunk[0] = 'X'
f, ok := sub.Recv()
if !ok {
t.Fatalf("Recv closed")
}
payload, err := protocol.Decode[protocol.PaneChunk](f)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if string(payload.Bytes) != "first" {
t.Fatalf("payload retained pump buffer: %q", string(payload.Bytes))
}
sub.OnPTYOut("p_123456", []byte("queued"))
sub.OnPTYOut("p_123456", []byte("dropped"))
if !sub.SnapshotRequired("p_123456") {
t.Fatalf("overflow did not mark pane snapshot required")
}
}

View File

@@ -0,0 +1,40 @@
package app
// ClientView is the per-client UI cursor over daemon-owned project/process
// state. In loopback mode there is one view, owned by uiState; future network
// clients will each get their own copy.
type ClientView struct {
ID string
ProjectKey string
ProjectName string
FocusedID string
FocusedPad string
ActiveAgentID string
PadOffset int
PadOffsetName string
Cols uint16
Rows uint16
}
func (v *ClientView) FocusChild(id string) {
v.FocusedID = id
v.FocusedPad = ""
}
func (v *ClientView) FocusPad(name string) {
v.FocusedID = ""
v.FocusedPad = name
if v.PadOffsetName != name {
v.PadOffset = 0
v.PadOffsetName = name
}
}
func (v *ClientView) ClearPadFocus() {
v.FocusedPad = ""
}
func (v *ClientView) Resize(cols, rows uint16) {
v.Cols = cols
v.Rows = rows
}

530
internal/app/daemon_core.go Normal file
View File

@@ -0,0 +1,530 @@
package app
import (
"context"
"fmt"
"path/filepath"
"sort"
"sync"
"syscall"
"time"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/projectkey"
"github.com/hjbdev/patterm/internal/protocol"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
)
type Project struct {
Key string
Dir string
Name string
Session *Session
Pads *scratchpad.Store
Trust *trust.Store
Persist *persist.Store
Launcher *Launcher
Host *toolHost
savedProcess []persist.Entry
displayMu sync.Mutex
displayOwners map[string]paneDisplayOwner
lastActive time.Time
}
type paneDisplayOwner struct {
ClientID string
Size protocol.Size
}
type projectSummary struct {
Key string
Dir string
Name string
TabCount int
IsCurrent bool
}
// ProjectRegistry is the daemon-owned project map. Phase 1 still runs in one
// local process, but every project already has isolated stores, session,
// launcher, and tool host so future clients can attach to different projects.
type ProjectRegistry struct {
mu sync.Mutex
projects map[string]*Project
defaultProjectKey string
presets preset.Set
settings settings
mcpSrv *mcp.Server
cols, rows uint16
}
func newProjectRegistry(presets preset.Set, settings settings, mcpSrv *mcp.Server, cols, rows uint16) *ProjectRegistry {
return &ProjectRegistry{
projects: make(map[string]*Project),
presets: presets,
settings: settings,
mcpSrv: mcpSrv,
cols: cols,
rows: rows,
}
}
func (r *ProjectRegistry) Open(ctx context.Context, dir string) (*Project, error) {
key, err := projectkey.Key(dir)
if err != nil {
return nil, err
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
r.mu.Lock()
if p := r.projects[key]; p != nil {
p.lastActive = time.Now()
r.mu.Unlock()
return p, nil
}
r.mu.Unlock()
pads, err := scratchpad.Open(key)
if err != nil {
return nil, fmt.Errorf("app: scratchpad init: %w", err)
}
trustStore, err := trust.Open(key)
if err != nil {
return nil, fmt.Errorf("app: trust init: %w", err)
}
persistStore, err := persist.Open(key)
if err != nil {
return nil, fmt.Errorf("app: persist init: %w", err)
}
sess := NewSession(abs, key)
savedProcesses := persistStore.List()
for _, e := range savedProcesses {
_ = persistStore.Remove(e.ID)
}
sess.SetPersistStore(persistStore)
socket := ""
if r.mcpSrv != nil {
socket = r.mcpSrv.Socket()
}
launcher := NewLauncher(sess, socket, r.cols, r.rows)
host := newToolHost(sess, pads, launcher, r.presets, trustStore, r.cols, r.rows)
go sess.runClassifier(ctx)
p := &Project{
Key: key,
Dir: abs,
Name: filepath.Base(abs),
Session: sess,
Pads: pads,
Trust: trustStore,
Persist: persistStore,
Launcher: launcher,
Host: host,
savedProcess: savedProcesses,
displayOwners: make(map[string]paneDisplayOwner),
lastActive: time.Now(),
}
r.mu.Lock()
if existing := r.projects[key]; existing != nil {
r.mu.Unlock()
sess.Shutdown()
return existing, nil
}
r.projects[key] = p
if r.defaultProjectKey == "" {
r.defaultProjectKey = key
}
r.mu.Unlock()
return p, nil
}
func (r *ProjectRegistry) Project(key string) *Project {
r.mu.Lock()
defer r.mu.Unlock()
return r.projects[key]
}
func (r *ProjectRegistry) Count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.projects)
}
func (r *ProjectRegistry) DefaultProject() *Project {
r.mu.Lock()
defer r.mu.Unlock()
return r.projects[r.defaultProjectKey]
}
func (p *Project) ClaimPaneDisplay(clientID, paneID string, size protocol.Size) (protocol.Size, bool) {
if p == nil || paneID == "" {
return size, true
}
if size.Cols == 0 || size.Rows == 0 {
size = protocol.Size{Cols: 80, Rows: 24}
}
p.displayMu.Lock()
if p.displayOwners == nil {
p.displayOwners = make(map[string]paneDisplayOwner)
}
owner, ok := p.displayOwners[paneID]
if !ok || owner.ClientID == "" || owner.ClientID == clientID {
p.displayOwners[paneID] = paneDisplayOwner{ClientID: clientID, Size: size}
p.displayMu.Unlock()
p.Session.ResizeChild(paneID, size.Cols, size.Rows)
return size, true
}
p.displayMu.Unlock()
return owner.Size, false
}
func (p *Project) ResizeClientDisplays(clientID string, size protocol.Size) {
if p == nil || size.Cols == 0 || size.Rows == 0 {
return
}
p.displayMu.Lock()
var panes []string
for paneID, owner := range p.displayOwners {
if owner.ClientID != clientID {
continue
}
owner.Size = size
p.displayOwners[paneID] = owner
panes = append(panes, paneID)
}
p.displayMu.Unlock()
for _, paneID := range panes {
p.Session.ResizeChild(paneID, size.Cols, size.Rows)
}
p.Launcher.SetSize(size.Cols, size.Rows)
p.Host.SetSize(size.Cols, size.Rows)
}
func (p *Project) ReleaseClientDisplays(clientID string) {
if p == nil {
return
}
p.displayMu.Lock()
for paneID, owner := range p.displayOwners {
if owner.ClientID == clientID {
delete(p.displayOwners, paneID)
}
}
p.displayMu.Unlock()
}
func (p *Project) PaneDisplay(paneID string) (protocol.Size, string, bool) {
if p == nil || paneID == "" {
return protocol.Size{}, "", false
}
p.displayMu.Lock()
defer p.displayMu.Unlock()
owner, ok := p.displayOwners[paneID]
return owner.Size, owner.ClientID, ok
}
func (r *ProjectRegistry) Shutdown() {
r.mu.Lock()
projects := make([]*Project, 0, len(r.projects))
for _, p := range r.projects {
projects = append(projects, p)
}
r.mu.Unlock()
for _, p := range projects {
p.Session.Shutdown()
}
}
func (r *ProjectRegistry) ResizeAll(cols, rows uint16) {
r.mu.Lock()
r.cols, r.rows = cols, rows
projects := make([]*Project, 0, len(r.projects))
for _, p := range r.projects {
projects = append(projects, p)
}
r.mu.Unlock()
for _, p := range projects {
p.Session.ResizeAll(cols, rows)
p.Launcher.SetSize(cols, rows)
p.Host.SetSize(cols, rows)
}
}
func (r *ProjectRegistry) Summaries(currentKey string) []projectSummary {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]projectSummary, 0, len(r.projects))
for _, p := range r.projects {
out = append(out, projectSummary{
Key: p.Key,
Dir: p.Dir,
Name: p.Name,
TabCount: len(runningTopLevels(p.Session.Children())),
IsCurrent: p.Key == currentKey,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].IsCurrent != out[j].IsCurrent {
return out[i].IsCurrent
}
return out[i].Name < out[j].Name
})
return out
}
func (r *ProjectRegistry) findProjectByChild(id string) (*Project, *Child) {
if id == "" {
return nil, nil
}
r.mu.Lock()
projects := make([]*Project, 0, len(r.projects))
for _, p := range r.projects {
projects = append(projects, p)
}
r.mu.Unlock()
for _, p := range projects {
if c := p.Session.FindChild(id); c != nil {
return p, c
}
}
return nil, nil
}
func (r *ProjectRegistry) projectForCaller(callerID string) *Project {
if p, _ := r.findProjectByChild(callerID); p != nil {
return p
}
r.mu.Lock()
defer r.mu.Unlock()
return r.projects[r.defaultProjectKey]
}
func (r *ProjectRegistry) hostForCaller(callerID string) *toolHost {
if p := r.projectForCaller(callerID); p != nil {
return p.Host
}
return nil
}
func (r *ProjectRegistry) hostForProcess(processID string) *toolHost {
if p, _ := r.findProjectByChild(processID); p != nil {
return p.Host
}
return nil
}
func (r *ProjectRegistry) ResolveCallerIdentity(identity string) string {
r.mu.Lock()
projects := make([]*Project, 0, len(r.projects))
for _, p := range r.projects {
projects = append(projects, p)
}
r.mu.Unlock()
for _, p := range projects {
if c := p.Session.FindChildByIdentity(identity); c != nil {
return c.ID
}
}
return ""
}
func (r *ProjectRegistry) CallerRole(processID string) mcp.CallerRole {
if h := r.hostForCaller(processID); h != nil {
return h.CallerRole(processID)
}
return mcp.RoleOrchestrator
}
func (r *ProjectRegistry) SpawnAgent(callerID string, args mcp.SpawnAgentArgs) (mcp.ProcessInfo, error) {
return r.hostForCaller(callerID).SpawnAgent(callerID, args)
}
func (r *ProjectRegistry) SpawnProcess(callerID string, args mcp.SpawnProcessArgs) (mcp.ProcessInfo, error) {
return r.hostForCaller(callerID).SpawnProcess(callerID, args)
}
func (r *ProjectRegistry) StartProcess(callerID, processID string) (mcp.ProcessInfo, error) {
if h := r.hostForProcess(processID); h != nil {
return h.StartProcess(callerID, processID)
}
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) RestartProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
if h := r.hostForProcess(processID); h != nil {
return h.RestartProcess(callerID, processID, sig)
}
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) StopProcess(callerID, processID string, sig syscall.Signal) (mcp.ProcessInfo, error) {
if h := r.hostForProcess(processID); h != nil {
return h.StopProcess(callerID, processID, sig)
}
return mcp.ProcessInfo{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) CloseProcess(callerID, processID string) error {
if h := r.hostForProcess(processID); h != nil {
return h.CloseProcess(callerID, processID)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) RenameProcess(callerID, processID, name string) error {
if h := r.hostForProcess(processID); h != nil {
return h.RenameProcess(callerID, processID, name)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) SelectProcess(callerID, processID string) error {
if h := r.hostForProcess(processID); h != nil {
return h.SelectProcess(callerID, processID)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) ListProcesses(callerID, kindFilter string) []mcp.ProcessInfo {
if h := r.hostForCaller(callerID); h != nil {
return h.ListProcesses(callerID, kindFilter)
}
return nil
}
func (r *ProjectRegistry) GetProcessStatus(callerID, processID string) (mcp.ProcessStatus, error) {
if h := r.hostForProcess(processID); h != nil {
return h.GetProcessStatus(callerID, processID)
}
return mcp.ProcessStatus{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) {
return r.hostForCaller(callerID).GetProjectStatus(callerID)
}
func (r *ProjectRegistry) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) {
if h := r.hostForProcess(processID); h != nil {
return h.GetProcessOutput(callerID, processID, mode, sinceOffset)
}
return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) {
if h := r.hostForProcess(processID); h != nil {
return h.GetProcessRawOutput(callerID, processID, sinceOffset)
}
return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) {
if h := r.hostForProcess(processID); h != nil {
return h.SearchOutput(callerID, processID, pattern, kind, limit)
}
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (bool, string, error) {
if h := r.hostForProcess(processID); h != nil {
return h.WaitForPattern(callerID, processID, pattern, timeoutSeconds, scope)
}
return false, "", mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
if h := r.hostForProcess(processID); h != nil {
return h.GetProcessPorts(callerID, processID)
}
return nil, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendInputResult, error) {
if h := r.hostForProcess(args.ProcessID); h != nil {
return h.SendInput(callerID, args)
}
return mcp.SendInputResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID)
}
func (r *ProjectRegistry) SendMessage(callerID, targetID, message string) error {
if h := r.hostForProcess(targetID); h != nil {
return h.SendMessage(callerID, targetID, message)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", targetID)
}
func (r *ProjectRegistry) RequestHumanAttention(callerID, processID, reason string) error {
if h := r.hostForProcess(processID); h != nil {
return h.RequestHumanAttention(callerID, processID, reason)
}
return mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID)
}
func (r *ProjectRegistry) TimerWait(callerID string, seconds float64, label string) (string, error) {
return r.hostForCaller(callerID).TimerWait(callerID, seconds, label)
}
func (r *ProjectRegistry) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
return r.hostForCaller(callerID).TimerSet(callerID, args)
}
func (r *ProjectRegistry) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
return r.hostForCaller(callerID).TimerFireWhenIdleAny(callerID, args)
}
func (r *ProjectRegistry) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
return r.hostForCaller(callerID).TimerFireWhenIdleAll(callerID, args)
}
func (r *ProjectRegistry) TimerCancel(callerID, id string) error {
return r.hostForCaller(callerID).TimerCancel(callerID, id)
}
func (r *ProjectRegistry) TimerPause(callerID, id string) error {
return r.hostForCaller(callerID).TimerPause(callerID, id)
}
func (r *ProjectRegistry) TimerResume(callerID, id string) error {
return r.hostForCaller(callerID).TimerResume(callerID, id)
}
func (r *ProjectRegistry) TimerList(callerID string) ([]mcp.TimerInfo, error) {
return r.hostForCaller(callerID).TimerList(callerID)
}
func (r *ProjectRegistry) ScratchpadList(callerID string) ([]scratchpad.Entry, error) {
return r.hostForCaller(callerID).ScratchpadList(callerID)
}
func (r *ProjectRegistry) ScratchpadRead(callerID, name string) (string, string, error) {
return r.hostForCaller(callerID).ScratchpadRead(callerID, name)
}
func (r *ProjectRegistry) ScratchpadWrite(callerID, name, content, expectedRevision string) (string, error) {
return r.hostForCaller(callerID).ScratchpadWrite(callerID, name, content, expectedRevision)
}
func (r *ProjectRegistry) ScratchpadAppend(callerID, name, content string) error {
return r.hostForCaller(callerID).ScratchpadAppend(callerID, name, content)
}
func (r *ProjectRegistry) ScratchpadDelete(callerID, name string) error {
return r.hostForCaller(callerID).ScratchpadDelete(callerID, name)
}
func (r *ProjectRegistry) WhoAmI(callerID string) mcp.WhoAmI {
return r.hostForCaller(callerID).WhoAmI(callerID)
}
func (r *ProjectRegistry) Help(callerID, topic string) mcp.HelpResponse {
return r.hostForCaller(callerID).Help(callerID, topic)
}

481
internal/app/daemon_net.go Normal file
View File

@@ -0,0 +1,481 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/protocol"
)
type DaemonOptions struct {
ProjectDir string
SocketPath string
PidPath string
ListenAddr string
Token string
TokenOut io.Writer
ListenReady chan string
Cols uint16
Rows uint16
}
type DaemonStatus struct {
PID int
Socket string
Projects []protocol.Project
}
func RuntimeDaemonPaths() (socketPath, pidPath string, err error) {
base := os.Getenv("XDG_RUNTIME_DIR")
if base == "" {
base = os.TempDir()
}
dir := filepath.Join(base, "patterm")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", err
}
return filepath.Join(dir, "daemon.sock"), filepath.Join(dir, "daemon.pid"), nil
}
func RunDaemon(ctx context.Context, opts DaemonOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return err
}
opts.ProjectDir = cwd
}
if opts.SocketPath == "" || opts.PidPath == "" {
socket, pid, err := RuntimeDaemonPaths()
if err != nil {
return err
}
if opts.SocketPath == "" {
opts.SocketPath = socket
}
if opts.PidPath == "" {
opts.PidPath = pid
}
}
if opts.Cols == 0 {
opts.Cols = 80
}
if opts.Rows == 0 {
opts.Rows = 24
}
lockPath, err := prepareDaemonSocket(opts.SocketPath, opts.PidPath)
if err != nil {
return err
}
defer os.Remove(lockPath)
ln, err := net.Listen("unix", opts.SocketPath)
if err != nil {
return fmt.Errorf("daemon: listen %s: %w", opts.SocketPath, err)
}
defer ln.Close()
defer os.Remove(opts.SocketPath)
if err := os.Chmod(opts.SocketPath, 0o600); err != nil {
return err
}
if err := os.WriteFile(opts.PidPath, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
return err
}
defer os.Remove(opts.PidPath)
presets, err := preset.Load()
if err != nil {
return fmt.Errorf("daemon: load presets: %w", err)
}
appSettings, _, err := loadSettings()
if err != nil {
logf("daemon settings load: %v", err)
}
mcpSrv, err := mcp.Start()
if err != nil {
return fmt.Errorf("daemon: mcp start: %w", err)
}
defer mcpSrv.Close()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
registry := newProjectRegistry(presets, appSettings, mcpSrv, opts.Cols, opts.Rows)
defer registry.Shutdown()
mcpSrv.SetHost(registry)
if _, err := registry.Open(ctx, opts.ProjectDir); err != nil {
return err
}
var tcpLn net.Listener
tcpToken := opts.Token
if opts.ListenAddr != "" {
addr := normalizeListenAddr(opts.ListenAddr)
tcpToken, err = ensureDaemonToken(tcpToken)
if err != nil {
return err
}
tcpLn, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("daemon: listen tcp %s: %w", addr, err)
}
defer tcpLn.Close()
if opts.ListenReady != nil {
select {
case opts.ListenReady <- tcpLn.Addr().String():
default:
}
}
out := opts.TokenOut
if out == nil {
out = os.Stderr
}
fmt.Fprintf(out, "patterm daemon listening on %s\npatterm token: %s\n", tcpLn.Addr().String(), tcpToken)
}
var wg sync.WaitGroup
go func() {
<-ctx.Done()
_ = ln.Close()
if tcpLn != nil {
_ = tcpLn.Close()
}
}()
errCh := make(chan error, 2)
go acceptDaemonLoop(ctx, &wg, ln, "", cancel, registry, errCh)
if tcpLn != nil {
go acceptDaemonLoop(ctx, &wg, tcpLn, tcpToken, cancel, registry, errCh)
}
select {
case <-ctx.Done():
case err := <-errCh:
cancel()
wg.Wait()
return err
}
wg.Wait()
return nil
}
func acceptDaemonLoop(ctx context.Context, wg *sync.WaitGroup, ln net.Listener, authToken string, stop func(), registry *ProjectRegistry, errCh chan<- error) {
for {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
return
}
select {
case errCh <- err:
default:
}
return
}
wg.Add(1)
go func() {
defer wg.Done()
handleDaemonConn(ctx, stop, registry, protocol.NewConnTransport(conn), authToken)
}()
}
}
func normalizeListenAddr(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return ""
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.HasPrefix(addr, ":") {
return addr
}
if _, err := strconv.Atoi(addr); err == nil {
return ":" + addr
}
return addr
}
func ensureDaemonToken(token string) (string, error) {
if strings.TrimSpace(token) != "" {
return strings.TrimSpace(token), nil
}
return LoadOrCreateClientToken()
}
func prepareDaemonSocket(socketPath, pidPath string) (string, error) {
if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
return "", err
}
lockPath := pidPath + ".lock"
if data, err := os.ReadFile(pidPath); err == nil {
if pid, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && pid > 0 {
if sigErr := syscallSignal0(pid); sigErr == nil {
return "", fmt.Errorf("daemon already running with pid %d", pid)
}
}
}
_ = os.Remove(socketPath)
_ = os.Remove(pidPath)
_ = os.Remove(lockPath)
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return "", fmt.Errorf("daemon: lock %s: %w", lockPath, err)
}
_, _ = f.WriteString(strconv.Itoa(os.Getpid()) + "\n")
_ = f.Close()
return lockPath, nil
}
func syscallSignal0(pid int) error {
return syscall.Kill(pid, 0)
}
func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport, authToken string) {
defer t.Close()
f, err := t.Recv()
if err != nil {
return
}
switch f.Type {
case protocol.FrameList:
_ = sendProjectList(t, registry, "")
return
case protocol.FrameStop:
_ = sendProjectList(t, registry, "")
stop()
return
case protocol.FrameAttach:
if authToken != "" {
attach, err := protocol.Decode[protocol.Attach](f)
if err != nil {
_ = sendProtocolError(t, err.Error())
return
}
if attach.Token != authToken {
_ = sendProtocolError(t, "auth denied")
return
}
}
handleDaemonAttach(ctx, registry, t, f)
default:
_ = sendProtocolError(t, fmt.Sprintf("first frame must be attach, list, or stop; got %q", f.Type))
}
}
func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protocol.Transport, first protocol.Frame) {
attach, err := protocol.Decode[protocol.Attach](first)
if err != nil {
_ = sendProtocolError(t, err.Error())
return
}
project := registry.Project(attach.ProjectKey)
if project == nil && attach.ProjectPath != "" {
project, err = registry.Open(ctx, attach.ProjectPath)
if err != nil {
_ = sendProtocolError(t, err.Error())
return
}
}
if project == nil {
project = registry.DefaultProject()
}
if project == nil {
_ = sendProtocolError(t, "no project open")
return
}
clientID := fmt.Sprintf("c-%d", time.Now().UnixNano())
view := ClientView{
ID: clientID,
ProjectKey: project.Key,
ProjectName: project.Name,
Cols: attach.TermSize.Cols,
Rows: attach.TermSize.Rows,
}
if child := firstRunningTopLevel(project.Session.Children()); child != nil {
view.FocusChild(child.ID)
project.ClaimPaneDisplay(clientID, child.ID, attach.TermSize)
}
sub := newClientSubscriber(project, clientID, defaultClientSubscriberQueue)
project.Session.SubscribeClient(sub)
defer project.Session.UnsubscribeClient(sub)
defer project.ReleaseClientDisplays(clientID)
_ = sendHello(t, project, view.ID)
_ = sendProjectList(t, registry, project.Key)
_ = sendChrome(t, project, view)
if view.FocusedID != "" {
_ = sendSnapshot(t, project, clientID, view.FocusedID)
}
// Close the transport when the daemon context is cancelled (shutdown or
// `daemon stop`). Without this the t.Recv() loop below blocks forever on a
// still-connected client and the accept loop's wg.Wait() never returns.
go func() {
<-ctx.Done()
_ = t.Close()
}()
done := make(chan struct{})
go func() {
defer close(done)
for {
f, ok := sub.Recv()
if !ok {
return
}
if err := t.Send(f); err != nil {
return
}
}
}()
for {
f, err := t.Recv()
if err != nil {
return
}
switch f.Type {
case protocol.FrameDetach:
return
case protocol.FrameInput:
msg, err := protocol.Decode[protocol.Input](f)
if err == nil {
if c := project.Session.FindChild(msg.PaneID); c != nil {
_ = c.InjectAsUser(msg.Bytes)
}
}
case protocol.FrameResize:
msg, err := protocol.Decode[protocol.Resize](f)
if err == nil {
view.Resize(msg.Size.Cols, msg.Size.Rows)
if view.FocusedID != "" {
if _, _, ok := project.PaneDisplay(view.FocusedID); !ok {
project.ClaimPaneDisplay(clientID, view.FocusedID, msg.Size)
}
}
project.ResizeClientDisplays(clientID, msg.Size)
}
case protocol.FrameFocus:
msg, err := protocol.Decode[protocol.Focus](f)
if err == nil && msg.PaneID != "" {
view.FocusChild(msg.PaneID)
project.ClaimPaneDisplay(clientID, msg.PaneID, protocol.Size{Cols: view.Cols, Rows: view.Rows})
_ = sendChrome(t, project, view)
_ = sendSnapshot(t, project, clientID, msg.PaneID)
}
case protocol.FramePaletteCommand:
if child := handleDaemonPaletteCommand(project, f); child != nil {
view.FocusChild(child.ID)
project.ClaimPaneDisplay(clientID, child.ID, protocol.Size{Cols: view.Cols, Rows: view.Rows})
_ = sendChrome(t, project, view)
_ = sendSnapshot(t, project, clientID, child.ID)
}
}
select {
case <-done:
return
default:
}
}
}
func handleDaemonPaletteCommand(project *Project, f protocol.Frame) *Child {
msg, err := protocol.Decode[protocol.PaletteCommand](f)
if err != nil {
return nil
}
switch msg.Kind {
case "spawn_command":
var p struct {
Argv []string `json:"argv"`
Name string `json:"name"`
WorkDir string `json:"working_dir"`
Shell bool `json:"shell"`
}
if err := json.Unmarshal(msg.Data, &p); err != nil || len(p.Argv) == 0 {
return nil
}
name := p.Name
if name == "" {
name = strings.Join(p.Argv, " ")
}
c, err := project.Launcher.LaunchCommandArgv(p.Argv, name, "", p.WorkDir, nil, p.Shell)
if err != nil {
return nil
}
return c
}
return nil
}
func sendHello(t protocol.Transport, p *Project, clientID string) error {
f, err := protocol.NewFrame(protocol.FrameHello, protocol.Hello{Version: 1, DaemonID: strconv.Itoa(os.Getpid()), ClientID: clientID, ProjectKey: p.Key})
if err != nil {
return err
}
return t.Send(f)
}
func sendProjectList(t protocol.Transport, registry *ProjectRegistry, current string) error {
summaries := registry.Summaries(current)
projects := make([]protocol.Project, 0, len(summaries))
for _, p := range summaries {
projects = append(projects, protocol.Project{Key: p.Key, Path: p.Dir, Name: p.Name, TabCount: p.TabCount})
}
f, err := protocol.NewFrame(protocol.FrameProjectList, protocol.ProjectList{Projects: projects})
if err != nil {
return err
}
return t.Send(f)
}
func sendChrome(t protocol.Transport, p *Project, view ClientView) error {
pads, _ := p.Pads.List()
model := buildChromeModel(p.Key, view, p.Session.Children(), pads)
b, err := json.Marshal(model)
if err != nil {
return err
}
f, err := protocol.NewFrame(protocol.FrameChrome, protocol.Chrome{ProjectKey: p.Key, Model: b})
if err != nil {
return err
}
return t.Send(f)
}
func sendSnapshot(t protocol.Transport, p *Project, clientID, paneID string) error {
b, err := p.Session.SerializeChild(paneID)
if err != nil {
return nil
}
size, ownerID, _ := p.PaneDisplay(paneID)
f, err := protocol.NewFrame(protocol.FramePaneSnapshot, protocol.PaneSnapshot{
PaneID: paneID,
Bytes: b,
Size: size,
DisplayOwner: ownerID == "" || ownerID == clientID,
})
if err != nil {
return err
}
return t.Send(f)
}
func sendProtocolError(t protocol.Transport, msg string) error {
f, err := protocol.NewFrame(protocol.FrameError, protocol.Error{Message: msg})
if err != nil {
return err
}
return t.Send(f)
}

View File

@@ -0,0 +1,477 @@
package app
import (
"context"
"encoding/json"
"io"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/protocol"
)
func TestDaemonDetachReattachPreservesProcess(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data"))
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
projectDir := filepath.Join(root, "project")
if err := os.MkdirAll(projectDir, 0o700); err != nil {
t.Fatalf("mkdir project: %v", err)
}
socket := filepath.Join(root, "runtime", "patterm", "daemon.sock")
pid := filepath.Join(root, "runtime", "patterm", "daemon.pid")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- RunDaemon(ctx, DaemonOptions{
ProjectDir: projectDir,
SocketPath: socket,
PidPath: pid,
Cols: 80,
Rows: 24,
})
}()
waitForSocket(t, socket, errCh)
client1 := dialDaemon(t, socket)
sendFrame(t, client1, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, client1, protocol.FrameHello)
expectFrame(t, client1, protocol.FrameProjectList)
expectFrame(t, client1, protocol.FrameChrome)
data, _ := json.Marshal(map[string]any{
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; while :; do echo STILL-HERE; sleep 1; done"},
"name": "survivor",
})
sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
waitForLifecycle(t, client1, protocol.LifecycleSpawned, 3*time.Second)
sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{})
_ = client1.Close()
client2 := dialDaemon(t, socket)
defer client2.Close()
sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, client2, protocol.FrameHello)
expectFrame(t, client2, protocol.FrameProjectList)
chrome := expectChrome(t, client2)
if !chromeHasProcess(chrome, "survivor") {
t.Fatalf("reattached chrome did not include surviving process: %s", string(chrome.Model))
}
expectFrame(t, client2, protocol.FramePaneSnapshot)
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("daemon returned error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatalf("daemon did not stop")
}
}
func TestDaemonTCPTokenAuthAndUnixExemption(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data"))
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
projectDir := filepath.Join(root, "project")
if err := os.MkdirAll(projectDir, 0o700); err != nil {
t.Fatalf("mkdir project: %v", err)
}
socket := filepath.Join(root, "runtime", "patterm", "daemon.sock")
pid := filepath.Join(root, "runtime", "patterm", "daemon.pid")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
ready := make(chan string, 1)
go func() {
errCh <- RunDaemon(ctx, DaemonOptions{
ProjectDir: projectDir,
SocketPath: socket,
PidPath: pid,
ListenAddr: "127.0.0.1:0",
Token: "secret-token",
TokenOut: io.Discard,
ListenReady: ready,
Cols: 80,
Rows: 24,
})
}()
waitForSocket(t, socket, errCh)
tcpAddr := waitForTCPAddr(t, ready, errCh)
assertTCPAttachDenied(t, tcpAddr, "")
assertTCPAttachDenied(t, tcpAddr, "wrong-token")
tcpClient := dialTCPDaemon(t, tcpAddr)
defer tcpClient.Close()
sendFrame(t, tcpClient, protocol.FrameAttach, protocol.Attach{
Token: "secret-token",
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, tcpClient, protocol.FrameHello)
expectFrame(t, tcpClient, protocol.FrameProjectList)
expectFrame(t, tcpClient, protocol.FrameChrome)
data, _ := json.Marshal(map[string]any{
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; echo TCP-SNAPSHOT; sleep 30"},
"name": "tcp-survivor",
})
sendFrame(t, tcpClient, protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
expectFrame(t, tcpClient, protocol.FramePaneSnapshot)
unixClient := dialDaemon(t, socket)
defer unixClient.Close()
sendFrame(t, unixClient, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, unixClient, protocol.FrameHello)
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("daemon returned error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatalf("daemon did not stop")
}
}
func TestDaemonPaneDisplayOwnerSizing(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
projectDir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24)
defer reg.Shutdown()
project, err := reg.Open(ctx, projectDir)
if err != nil {
t.Fatalf("open project: %v", err)
}
client1, daemon1 := protocol.NewLoopbackPair()
go handleDaemonConn(ctx, cancel, reg, daemon1, "")
sendFrame(t, client1, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
expectFrame(t, client1, protocol.FrameHello)
expectFrame(t, client1, protocol.FrameProjectList)
expectFrame(t, client1, protocol.FrameChrome)
data, _ := json.Marshal(map[string]any{
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
"name": "owner-pane",
})
sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
paneID := waitForLifecycleID(t, client1, protocol.LifecycleSpawned, 3*time.Second)
snap1 := waitForSnapshot(t, client1, paneID, 3*time.Second)
if !snap1.DisplayOwner || snap1.Size != (protocol.Size{Cols: 80, Rows: 24}) {
t.Fatalf("owner snapshot = owner:%v size:%+v, want owner true size 80x24", snap1.DisplayOwner, snap1.Size)
}
waitForEmulatorSize(t, project, paneID, 80, 24)
client2, daemon2 := protocol.NewLoopbackPair()
go handleDaemonConn(ctx, cancel, reg, daemon2, "")
sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{
ProjectPath: projectDir,
TermSize: protocol.Size{Cols: 100, Rows: 30},
})
expectFrame(t, client2, protocol.FrameHello)
expectFrame(t, client2, protocol.FrameProjectList)
expectFrame(t, client2, protocol.FrameChrome)
snap2 := waitForSnapshot(t, client2, paneID, 3*time.Second)
if snap2.DisplayOwner || snap2.Size != (protocol.Size{Cols: 80, Rows: 24}) {
t.Fatalf("viewer snapshot = owner:%v size:%+v, want owner false size 80x24", snap2.DisplayOwner, snap2.Size)
}
sendFrame(t, client2, protocol.FrameResize, protocol.Resize{Size: protocol.Size{Cols: 100, Rows: 30}})
time.Sleep(100 * time.Millisecond)
waitForEmulatorSize(t, project, paneID, 80, 24)
sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{})
_ = client1.Close()
time.Sleep(100 * time.Millisecond)
sendFrame(t, client2, protocol.FrameFocus, protocol.Focus{PaneID: paneID})
snap3 := waitForSnapshot(t, client2, paneID, 3*time.Second)
if !snap3.DisplayOwner || snap3.Size != (protocol.Size{Cols: 100, Rows: 30}) {
t.Fatalf("claimed snapshot = owner:%v size:%+v, want owner true size 100x30", snap3.DisplayOwner, snap3.Size)
}
waitForEmulatorSize(t, project, paneID, 100, 30)
sendFrame(t, client2, protocol.FrameDetach, protocol.Detach{})
_ = client2.Close()
}
func waitForSocket(t *testing.T, socket string, errCh <-chan error) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(socket); err == nil {
return
}
select {
case err := <-errCh:
if err != nil && strings.Contains(err.Error(), "operation not permitted") {
t.Skipf("unix sockets unavailable in this sandbox: %v", err)
}
t.Fatalf("daemon exited before creating socket: %v", err)
default:
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("socket %s was not created", socket)
}
func dialDaemon(t *testing.T, socket string) protocol.Transport {
t.Helper()
conn, err := net.Dial("unix", socket)
if err != nil {
t.Fatalf("dial daemon: %v", err)
}
return protocol.NewConnTransport(conn)
}
func dialTCPDaemon(t *testing.T, addr string) protocol.Transport {
t.Helper()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("dial tcp daemon: %v", err)
}
return protocol.NewConnTransport(conn)
}
func waitForTCPAddr(t *testing.T, ready <-chan string, errCh <-chan error) string {
t.Helper()
select {
case addr := <-ready:
return addr
case err := <-errCh:
if err != nil && strings.Contains(err.Error(), "operation not permitted") {
t.Skipf("tcp sockets unavailable in this sandbox: %v", err)
}
t.Fatalf("daemon exited before TCP listener was ready: %v", err)
case <-time.After(3 * time.Second):
t.Fatalf("tcp listener was not ready")
}
return ""
}
func assertTCPAttachDenied(t *testing.T, addr, token string) {
t.Helper()
client := dialTCPDaemon(t, addr)
defer client.Close()
sendFrame(t, client, protocol.FrameAttach, protocol.Attach{
Token: token,
TermSize: protocol.Size{Cols: 80, Rows: 24},
})
f := expectFrame(t, client, protocol.FrameError)
msg, err := protocol.Decode[protocol.Error](f)
if err != nil {
t.Fatalf("decode error frame: %v", err)
}
if !strings.Contains(msg.Message, "auth denied") {
t.Fatalf("error message = %q, want auth denied", msg.Message)
}
}
func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
t.Helper()
f, err := protocol.NewFrame(typ, payload)
if err != nil {
t.Fatalf("frame %s: %v", typ, err)
}
if err := tr.Send(f); err != nil {
t.Fatalf("send %s: %v", typ, err)
}
}
func expectFrame(t *testing.T, tr protocol.Transport, typ protocol.FrameType) protocol.Frame {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
if !ok {
break
}
if err != nil {
t.Fatalf("recv %s: %v", typ, err)
}
if f.Type == typ {
return f
}
}
t.Fatalf("frame %s not received", typ)
return protocol.Frame{}
}
func expectChrome(t *testing.T, tr protocol.Transport) protocol.Chrome {
t.Helper()
f := expectFrame(t, tr, protocol.FrameChrome)
chrome, err := protocol.Decode[protocol.Chrome](f)
if err != nil {
t.Fatalf("decode chrome: %v", err)
}
return chrome
}
func waitForLifecycle(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
if !ok {
break
}
if err != nil {
t.Fatalf("recv lifecycle: %v", err)
}
if f.Type != protocol.FrameLifecycle {
continue
}
msg, err := protocol.Decode[protocol.Lifecycle](f)
if err != nil {
t.Fatalf("decode lifecycle: %v", err)
}
if msg.Kind == kind {
return
}
}
t.Fatalf("lifecycle %s not received", kind)
}
func waitForLifecycleID(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) string {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
if !ok {
break
}
if err != nil {
t.Fatalf("recv lifecycle: %v", err)
}
if f.Type != protocol.FrameLifecycle {
continue
}
msg, err := protocol.Decode[protocol.Lifecycle](f)
if err != nil {
t.Fatalf("decode lifecycle: %v", err)
}
if msg.Kind == kind {
return msg.ChildID
}
}
t.Fatalf("lifecycle %s not received", kind)
return ""
}
func waitForSnapshot(t *testing.T, tr protocol.Transport, paneID string, timeout time.Duration) protocol.PaneSnapshot {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
if !ok {
break
}
if err != nil {
t.Fatalf("recv snapshot: %v", err)
}
if f.Type != protocol.FramePaneSnapshot {
continue
}
msg, err := protocol.Decode[protocol.PaneSnapshot](f)
if err != nil {
t.Fatalf("decode snapshot: %v", err)
}
if msg.PaneID == paneID {
return msg
}
}
t.Fatalf("snapshot for %s not received", paneID)
return protocol.PaneSnapshot{}
}
func waitForEmulatorSize(t *testing.T, project *Project, paneID string, cols, rows uint16) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if c := project.Session.FindChild(paneID); c != nil {
if em := c.Emulator(); em != nil {
gotCols, gotRows := em.Size()
if gotCols == cols && gotRows == rows {
return
}
}
}
time.Sleep(25 * time.Millisecond)
}
if c := project.Session.FindChild(paneID); c != nil {
if em := c.Emulator(); em != nil {
gotCols, gotRows := em.Size()
t.Fatalf("emulator size = %dx%d, want %dx%d", gotCols, gotRows, cols, rows)
}
}
t.Fatalf("pane %s missing emulator", paneID)
}
func recvFrameWithin(tr protocol.Transport, timeout time.Duration) (protocol.Frame, error, bool) {
type result struct {
f protocol.Frame
err error
}
ch := make(chan result, 1)
go func() {
f, err := tr.Recv()
ch <- result{f: f, err: err}
}()
select {
case r := <-ch:
return r.f, r.err, true
case <-time.After(timeout):
return protocol.Frame{}, nil, false
}
}
func chromeHasProcess(chrome protocol.Chrome, name string) bool {
var model struct {
Processes []childModel `json:"processes"`
}
if err := json.Unmarshal(chrome.Model, &model); err != nil {
return false
}
for _, p := range model.Processes {
if p.Name == name {
return true
}
}
return false
}

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

@@ -0,0 +1,162 @@
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) OnChildClosed(id string) {
d.writeEvent("child_closed", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": id,
})
}
func (d *debugCapture) OnPTYOut(childID string, chunk []byte) {
if len(chunk) == 0 {
return
}
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

@@ -7,6 +7,7 @@ import (
"sync"
"syscall"
"time"
"unicode"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
@@ -61,12 +62,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 +76,31 @@ 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 and OnChildClosed
// into the timer manager and ignores the other ChildEventListener
// methods. The session's listener API is by-interface, so we wrap
// the manager rather than make it implement the full surface.
type timerListenerAdapter struct{ m *timerManager }
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
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 (a timerListenerAdapter) OnChildClosed(id string) {
a.m.onChildClosed(id)
}
func (h *toolHost) SetSize(cols, rows uint16) {
@@ -374,7 +399,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
}
out.Content = txt
out.Content = normalizeGridText(txt)
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
@@ -531,6 +556,8 @@ func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
default:
}
}
func (n *chunkNotifier) OnChildStateChanged(string, IdleState) {}
func (n *chunkNotifier) OnChildClosed(string) {}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
@@ -725,40 +752,72 @@ 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
}
// ───────────────────────────────────────────────────────────────────
// Scratchpads / Meta
// ───────────────────────────────────────────────────────────────────
func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return h.pads.List() }
func (h *toolHost) ScratchpadRead(name string) (string, string, error) {
func (h *toolHost) ScratchpadRead(_ string, name string) (string, string, error) {
return h.pads.Read(name)
}
func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) {
func (h *toolHost) ScratchpadWrite(_, name, content, expectedRevision string) (string, error) {
rev, err := h.pads.Write(name, content, expectedRevision)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
@@ -766,7 +825,7 @@ func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (stri
return rev, err
}
func (h *toolHost) ScratchpadAppend(name, content string) error {
func (h *toolHost) ScratchpadAppend(_, name, content string) error {
err := h.pads.Append(name, content)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
@@ -774,6 +833,14 @@ func (h *toolHost) ScratchpadAppend(name, content string) error {
return err
}
func (h *toolHost) ScratchpadDelete(_, name string) error {
err := h.pads.Delete(name)
if err == nil && h.scratch != nil {
h.scratch.scratchpadsChanged()
}
return err
}
func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI {
w := mcp.WhoAmI{
ProcessID: callerID,
@@ -816,6 +883,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
}
@@ -948,6 +1019,30 @@ func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
func normalizeGridText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
pendingBlank := false
for _, line := range lines {
line = strings.TrimRightFunc(line, unicode.IsSpace)
if line == "" {
if len(out) > 0 {
pendingBlank = true
}
continue
}
if pendingBlank {
out = append(out, "")
pendingBlank = false
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a
@@ -1026,8 +1121,10 @@ 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",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append",
"send_input", "send_message", "request_human_attention",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete",
"whoami", "help",
}
if role == mcp.RoleOrchestrator {
@@ -1051,13 +1148,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":
@@ -1074,27 +1171,42 @@ func helpFor(topic string) mcp.HelpResponse {
}
case "coordination":
return mcp.HelpResponse{
Topic: "coordination",
Content: "send_message tags the message with the caller's role (parent → [orchestrator], child → [sub-agent:<name>]). Siblings must route through their parent. request_human_attention raises a UI notification when you can't safely decide.",
RelatedTools: []string{"send_message", "request_human_attention"},
Topic: "coordination",
Content: "send_message tags the message with the caller's role (parent → [orchestrator], child → [sub-agent:<name>]). Siblings must route through their parent. request_human_attention raises a UI notification when you can't safely decide.\n\n" +
"Reply routing: a sub-agent's reply to your send_message lands in YOUR pane tagged `[sub-agent:<name>]`, not in the sub-agent's output. Anti-pattern: `wait_for_pattern(sub_agent, …)` to wait for a reply — the sub-agent is already idle, its output won't change, and the call spins to timeout. Pattern: send_message → timer_fire_when_idle_any([sub_agent_id], body=\"[system] sub-agent finished\") → when the timer fires, the reply is already queued as your next user turn (or visible via get_process_output on your own pane).",
RelatedTools: []string{"send_message", "request_human_attention", "timer_fire_when_idle_any", "timer_fire_when_idle_all"},
}
case "scratchpads":
return mcp.HelpResponse{
Topic: "scratchpads",
Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append"},
Content: "Project-scoped markdown files. Read returns content + revision; pass that back as expected_revision on write to get last-write-wins-with-detection. Append is unconditional; delete removes a pad by name.",
RelatedTools: []string{"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append", "scratchpad_delete"},
}
case "timers":
return mcp.HelpResponse{
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{
Topic: "readiness",
Content: "A pane is 'idle' once nothing has been written to its PTY for ~1s (SPEC §11). Treat idle as a signal to read, not a guarantee of completion. wait_for_pattern lets you wait on a known terminal marker for stronger evidence.",
RelatedTools: []string{"wait_for_pattern", "get_process_status"},
Topic: "readiness",
Content: "A pane is 'idle' once nothing has been written to its PTY for ~1s (SPEC §11). Treat idle as a signal to read, not a guarantee of completion.\n\n" +
"Waiting for a sub-agent's reply (canonical pattern):\n" +
" 1. send_message(sub_agent_id, request)\n" +
" 2. timer_fire_when_idle_any(watched=[sub_agent_id], body=\"[system] sub-agent done\")\n" +
" 3. When the timer fires you re-enter as a fresh user turn; the sub-agent's reply is already in your own pane tagged `[sub-agent:<name>]` (read via get_process_output on yourself if you need it explicitly).\n\n" +
"wait_for_pattern is for waiting on text a process emits in its OWN output (a shell prompt, a build's \"tests passed\" line). It does NOT see send_message replies, because those land in the caller's pane, not the target's — calling wait_for_pattern on a sub-agent to wait for its reply deadlocks until timeout.",
RelatedTools: []string{"wait_for_pattern", "get_process_status", "timer_fire_when_idle_any", "send_message"},
}
case "permissions":
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
}

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

@@ -0,0 +1,228 @@
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
// - screen: current rendered screen text for persistent prompt matching
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail, screen []byte) (IdleState, string) {
if exited {
if exitNonZero {
return StateError, "process exited non-zero"
}
return StateIdle, "process exited cleanly"
}
if cfg == nil {
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
}
if len(tail) > 0 || len(screen) > 0 {
if matchAny(cfg.errorRegexes, tail, screen) {
return StateError, "error regex matched"
}
if matchAny(cfg.permissionRegexes, tail, screen) {
return StatePermission, "permission regex matched"
}
if matchAny(cfg.thinkingRegexes, tail, screen) {
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, texts ...[]byte) bool {
for _, re := range res {
for _, text := range texts {
if len(text) > 0 && re.Match(text) {
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)
}

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

@@ -0,0 +1,132 @@
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, 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, 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, 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, nil); got != StateWorking {
t.Fatalf("no title yet, recent output: got %q", got)
}
if got, _ := classify(cfg, false, false, 5000, 0, "", nil, nil); got != StateIdle {
t.Fatalf("no title yet, output idle: got %q", got)
}
}
func TestClassifyTitleStabilityThinkingPatternOverridesIdle(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStability,
idleThresholdMS: 2000,
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `(?i)esc to interrupt`)},
}
screen := []byte("• Working (5s • esc to interrupt)")
if got, _ := classify(cfg, false, false, 9999, 5000, "codex", nil, screen); got != StateThinking {
t.Fatalf("thinking screen marker: got %q want %q", got, StateThinking)
}
if got, _ := classify(cfg, false, false, 9999, 5000, "codex", nil, []byte(">_")); got != StateIdle {
t.Fatalf("stable title without marker: got %q want %q", got, StateIdle)
}
}
func TestClassifyTitleStatus(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStatus,
idleThresholdMS: 2000,
titleStatusMap: map[string]IdleState{
"thinking": StateThinking,
"permission": StatePermission,
"error": StateError,
},
}
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil, nil); got != StateThinking {
t.Fatalf("thinking title: got %q", got)
}
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil, 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, 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]"), nil); got != StatePermission {
t.Fatalf("permission promoter: got %q", got)
}
// Error trumps permission.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?"), nil); got != StateError {
t.Fatalf("error promoter beats permission: got %q", got)
}
// Thinking promoter on idle output.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…"), nil); got != StateThinking {
t.Fatalf("thinking promoter: got %q", got)
}
// Rendered-screen prompts still promote even when the raw tail no
// longer contains the original prompt bytes.
if got, _ := classify(cfg, false, false, 100, 0, "", []byte("Calling patterm..."), []byte("Approve? [y/n]")); got != StatePermission {
t.Fatalf("screen permission promoter: got %q", got)
}
}
func TestClassifyExitTerminal(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
if got, _ := classify(cfg, true, true, 0, 0, "", nil, nil); got != StateError {
t.Fatalf("non-zero exit: got %q", got)
}
if got, _ := classify(cfg, true, false, 0, 0, "", nil, 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
@@ -255,15 +261,11 @@ func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir
}
func (l *Launcher) writeMCPConfig(identity string) (string, error) {
dir, err := preset.ConfigDir()
dir, err := mcpRuntimeDir(identity)
if err != nil {
return "", err
}
dir = filepath.Join(dir, "mcp")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
path := filepath.Join(dir, identity+".json")
path := filepath.Join(dir, "mcp.json")
cfg := map[string]any{
"mcpServers": map[string]any{
"patterm": map[string]any{

View File

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

View File

@@ -14,10 +14,10 @@ func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
if l.childCols() != 91 {
t.Fatalf("child cols: got %d want 91", l.childCols())
}
if l.childRows() != 37 {
t.Fatalf("child rows: got %d want 37", l.childRows())
if l.childRows() != 36 {
t.Fatalf("child rows: got %d want 36", l.childRows())
}
if l.mainTop != 3 || l.statusRow != 40 {
if l.mainTop != 4 || l.statusRow != 40 {
t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow)
}
}
@@ -30,8 +30,8 @@ func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) {
if l.childCols() != 38 {
t.Fatalf("child cols: got %d want 38", l.childCols())
}
if l.childRows() != 9 {
t.Fatalf("child rows: got %d want 9", l.childRows())
if l.childRows() != 8 {
t.Fatalf("child rows: got %d want 8", l.childRows())
}
}
@@ -46,13 +46,13 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
l := newTerminalLayout(120, 40)
launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
cols, rows := launcher.size()
if cols != 91 || rows != 37 {
t.Fatalf("launcher size: got %dx%d want 91x37", cols, rows)
if cols != 91 || rows != 36 {
t.Fatalf("launcher size: got %dx%d want 91x36", cols, rows)
}
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
cols, rows = host.size()
if cols != 91 || rows != 37 {
t.Fatalf("tool host size: got %dx%d want 91x37", cols, rows)
if cols != 91 || rows != 36 {
t.Fatalf("tool host size: got %dx%d want 91x36", cols, rows)
}
}

123
internal/app/marquee.go Normal file
View File

@@ -0,0 +1,123 @@
package app
import (
"sync"
"time"
)
// Phase ordering of the marquee state machine: hold the head, scroll
// one cell per marqueeStep until the tail is visible, hold the tail,
// snap back to the head.
const (
phaseHoldStart = iota
phaseScroll
phaseHoldEnd
)
const (
marqueeHoldStart = time.Second
marqueeStep = 150 * time.Millisecond
marqueeHoldEnd = time.Second
)
// marqueeState drives the focused sidebar row's pause-scroll-pause
// animation. State is wall-clock anchored (since), not tick-count
// anchored, so a missed tick yields a slightly later frame rather
// than a skipped one.
type marqueeState struct {
mu sync.Mutex
id string
nameLen int
budget int
state int
offset int
since time.Time
}
// step advances the state machine for the row identified by id with
// the given visible name length (in runes) and column budget. It
// returns the current scroll offset, whether the row is animating
// (i.e. nameLen > budget), and how long until the next visual change.
//
// When id changes, or nameLen <= budget, the state machine resets to
// phaseHoldStart with offset 0 anchored at now.
func (m *marqueeState) step(id string, nameLen, budget int, now time.Time) (offset int, animating bool, nextWake time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
if id != m.id || nameLen != m.nameLen || budget != m.budget {
m.id = id
m.nameLen = nameLen
m.budget = budget
m.state = phaseHoldStart
m.offset = 0
m.since = now
}
if nameLen <= budget || budget <= 0 {
return 0, false, 0
}
maxOffset := nameLen - budget
for {
elapsed := now.Sub(m.since)
switch m.state {
case phaseHoldStart:
if elapsed < marqueeHoldStart {
return 0, true, marqueeHoldStart - elapsed
}
m.state = phaseScroll
m.since = m.since.Add(marqueeHoldStart)
continue
case phaseScroll:
steps := int(elapsed / marqueeStep)
if steps >= maxOffset {
m.offset = maxOffset
m.state = phaseHoldEnd
m.since = m.since.Add(time.Duration(maxOffset) * marqueeStep)
continue
}
m.offset = steps
rem := marqueeStep - (elapsed % marqueeStep)
return m.offset, true, rem
case phaseHoldEnd:
if elapsed < marqueeHoldEnd {
return maxOffset, true, marqueeHoldEnd - elapsed
}
m.state = phaseHoldStart
m.offset = 0
m.since = m.since.Add(marqueeHoldEnd)
continue
default:
m.state = phaseHoldStart
m.offset = 0
m.since = now
return 0, true, marqueeHoldStart
}
}
}
// active reports whether the marquee currently has an overflowing row
// to animate. The marquee ticker goroutine uses this to gate dirty
// flag flips so an idle sidebar costs nothing.
func (m *marqueeState) active() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.id != "" && m.nameLen > m.budget && m.budget > 0
}
// reset clears all state, forcing the next step() call to start a
// fresh phaseHoldStart. Call this when focus changes so the newly
// focused row begins with a full head-hold instead of inheriting
// whatever phase the previous focus was in.
func (m *marqueeState) reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.id = ""
m.nameLen = 0
m.budget = 0
m.state = phaseHoldStart
m.offset = 0
m.since = time.Time{}
}

View File

@@ -0,0 +1,161 @@
package app
import (
"testing"
"time"
)
func TestMarqueeStepFits(t *testing.T) {
var m marqueeState
now := time.Unix(0, 0)
off, animating, _ := m.step("a", 5, 10, now)
if animating {
t.Fatalf("expected no animation when name fits in budget")
}
if off != 0 {
t.Fatalf("expected offset 0, got %d", off)
}
}
func TestMarqueePhaseProgression(t *testing.T) {
var m marqueeState
// name 10 runes, budget 5 → maxOffset = 5.
const nameLen, budget = 10, 5
t0 := time.Unix(0, 0)
// At t0: phaseHoldStart, offset 0, animating.
off, anim, wake := m.step("row", nameLen, budget, t0)
if off != 0 || !anim || wake != marqueeHoldStart {
t.Fatalf("t0: off=%d anim=%v wake=%v", off, anim, wake)
}
// Just before hold expires: still offset 0.
off, anim, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart-time.Millisecond))
if off != 0 || !anim {
t.Fatalf("pre-expiry hold: off=%d anim=%v", off, anim)
}
// At hold expiry + 1 step: should have transitioned to scroll, offset 1.
off, anim, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+marqueeStep))
if !anim || off != 1 {
t.Fatalf("first scroll step: off=%d anim=%v", off, anim)
}
// Mid-scroll: offset == 3.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+3*marqueeStep))
if off != 3 {
t.Fatalf("mid scroll: off=%d", off)
}
// Tail reached: offset == maxOffset == 5.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+time.Millisecond))
if off != 5 {
t.Fatalf("tail: off=%d", off)
}
// Hold-end window still pegged at maxOffset.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+marqueeHoldEnd/2))
if off != 5 {
t.Fatalf("hold-end mid: off=%d", off)
}
// After hold-end: snap back to offset 0.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+marqueeHoldEnd+time.Millisecond))
if off != 0 {
t.Fatalf("snap back: off=%d", off)
}
}
func TestMarqueeIDChangeResets(t *testing.T) {
var m marqueeState
t0 := time.Unix(0, 0)
_, _, _ = m.step("a", 10, 5, t0)
// Advance well into scroll for row "a".
_, _, _ = m.step("a", 10, 5, t0.Add(marqueeHoldStart+3*marqueeStep))
// Now focus moves to "b": offset must reset to 0 and phase to hold-start.
off, anim, wake := m.step("b", 10, 5, t0.Add(marqueeHoldStart+3*marqueeStep))
if off != 0 || !anim || wake != marqueeHoldStart {
t.Fatalf("id reset: off=%d anim=%v wake=%v", off, anim, wake)
}
}
func TestMarqueeActive(t *testing.T) {
var m marqueeState
if m.active() {
t.Fatalf("fresh marquee should not be active")
}
_, _, _ = m.step("row", 10, 5, time.Unix(0, 0))
if !m.active() {
t.Fatalf("expected active after overflow step")
}
_, _, _ = m.step("row", 4, 5, time.Unix(0, 0))
if m.active() {
t.Fatalf("should not be active when name fits")
}
}
func TestMarqueeReset(t *testing.T) {
var m marqueeState
_, _, _ = m.step("row", 10, 5, time.Unix(0, 0))
m.reset()
if m.active() {
t.Fatalf("expected inactive after reset")
}
// After reset, stepping the same id starts fresh.
off, _, wake := m.step("row", 10, 5, time.Unix(5, 0))
if off != 0 || wake != marqueeHoldStart {
t.Fatalf("post-reset start: off=%d wake=%v", off, wake)
}
}
func TestFitName(t *testing.T) {
cases := []struct {
name, in string
budget int
want string
}{
{"fits", "abc", 5, "abc"},
{"exact", "abcde", 5, "abcde"},
{"truncate", "abcdef", 5, "abcd…"},
{"budget1", "abcdef", 1, "…"},
{"budget0", "abc", 0, ""},
{"unicode", "αβγδεζη", 4, "αβγ…"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := fitName(c.in, c.budget)
if got != c.want {
t.Fatalf("fitName(%q, %d) = %q want %q", c.in, c.budget, got, c.want)
}
})
}
}
func TestMarqueeWindow(t *testing.T) {
got := marqueeWindow("abcdefgh", 4, 2)
if got != "cdef" {
t.Fatalf("window = %q", got)
}
// Clamp end-of-string overflow.
got = marqueeWindow("abcdef", 4, 10)
if got != "cdef" {
t.Fatalf("clamped window = %q", got)
}
}
func TestClampVisible(t *testing.T) {
// Plain string longer than width.
if got := clampVisible("abcdef", 3); visibleLen(got) != 3 {
t.Fatalf("plain clamp visible = %d (%q)", visibleLen(got), got)
}
// Already-fitting string is unchanged.
if got := clampVisible("abc", 5); got != "abc" {
t.Fatalf("unchanged = %q", got)
}
// SGR-wrapped string: visible portion must be <= width.
in := "\x1b[1mhello\x1b[0m world"
got := clampVisible(in, 5)
if visibleLen(got) != 5 {
t.Fatalf("sgr clamp visible = %d (%q)", visibleLen(got), got)
}
}

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()
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,14 +31,17 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
if i, _ := findItem(p, "pad-delete"); i != 0 {
t.Fatalf("pad-delete at %d; want top", i)
// With the dashed section header gone, pad-edit is the first row;
// pad-rename-form follows, with destructive pad-delete last in the
// Focused section.
if i, _ := findItem(p, "pad-edit"); i != 0 {
t.Fatalf("pad-edit at %d; want 0", i)
}
if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" {
t.Fatalf("pad-rename-form missing or wrong padName: %+v", it)
}
if _, it := findItem(p, "pad-edit"); it == nil {
t.Fatalf("pad-edit missing")
if i, _ := findItem(p, "pad-delete"); i < 0 {
t.Fatalf("pad-delete missing")
}
// No focused child → no agent/proc context items.
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
@@ -80,9 +83,31 @@ func TestContextItemsProcess(t *testing.T) {
}
}
func TestContextItemsTerminalUsesCloseNotStop(t *testing.T) {
c := makeFakeChild("tid", "terminal", KindTerminal)
p := newPalette([]*Child{c}, "tid", "", preset.Set{})
if _, it := findItem(p, "proc-stop"); it == nil || it.label != "Close" {
t.Fatalf("terminal close row missing or mislabelled: %+v", it)
}
if _, it := findItem(p, "proc-restart"); it == nil {
t.Fatalf("terminal restart row missing")
}
if i, _ := findItem(p, "proc-delete"); i != -1 {
t.Fatalf("terminal should not show a separate delete/close row, found at %d", i)
}
for i, it := range p.items {
if it.label == "Stop" {
t.Fatalf("terminal should not show Stop row, found at %d", i)
}
}
}
func TestContextItemsAppearAboveSwitch(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand)
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
// Two children so there's still a non-focused switch entry to compare
// against (the focused child is suppressed from the Open section).
focused := makeFakeChild("pid", "devserver", KindCommand)
other := makeFakeChild("oid", "worker", KindCommand)
p := newPalette([]*Child{focused, other}, "pid", "", preset.Set{})
procIdx, _ := findItem(p, "proc-rename-form")
switchIdx, _ := findItem(p, "switch")
if procIdx < 0 || switchIdx < 0 {

View File

@@ -47,36 +47,50 @@ func TestPaletteBareEscCancels(t *testing.T) {
}
}
// firstSelectable returns the lowest item index whose action is
// selectable (not a section header), or -1 if the palette has no
// selectable rows.
func firstSelectable(p *paletteState) int {
for i, it := range p.items {
if it.action.kind != "header" {
return i
}
}
return -1
}
func TestPaletteKittyArrowsNavigate(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
if p.cursor != 0 {
t.Fatalf("initial cursor %d", p.cursor)
first := firstSelectable(p)
if first < 0 || p.cursor != first {
t.Fatalf("initial cursor %d, want first selectable %d", p.cursor, first)
}
// Kitty functional Down arrow.
_, _, adv := p.handleInput([]byte("\x1b[57353u"), 0)
if adv != 8 {
t.Fatalf("advance %d", adv)
}
if p.cursor != 1 {
t.Fatalf("cursor %d after Down, want 1", p.cursor)
if p.cursor != first+1 {
t.Fatalf("cursor %d after Down, want %d", p.cursor, first+1)
}
// Kitty functional Up arrow.
_, _, _ = p.handleInput([]byte("\x1b[57352u"), 0)
if p.cursor != 0 {
t.Fatalf("cursor %d after Up, want 0", p.cursor)
if p.cursor != first {
t.Fatalf("cursor %d after Up, want %d", p.cursor, first)
}
}
func TestPaletteLegacyArrowsStillWork(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
first := firstSelectable(p)
_, _, adv := p.handleInput([]byte("\x1b[B"), 0)
if adv != 3 {
t.Fatalf("advance %d", adv)
}
if p.cursor != 1 {
t.Fatalf("cursor %d, want 1", p.cursor)
if p.cursor != first+1 {
t.Fatalf("cursor %d, want %d", p.cursor, first+1)
}
}

View File

@@ -0,0 +1,484 @@
package app
import (
"bytes"
"strings"
"testing"
"github.com/hjbdev/patterm/internal/preset"
)
// -- Phase 1: naming & dropped global Close list ---------------------
func TestPaletteVerbsAreUnified(t *testing.T) {
procs := []*preset.Preset{{Name: "dev"}}
agents := []*preset.Preset{{Name: "claude"}}
p := newPalette(nil, "", "", preset.Set{Agents: agents, Processes: procs})
gotLabels := make([]string, 0, len(p.items))
for _, it := range p.items {
if it.action.kind == "header" {
continue
}
gotLabels = append(gotLabels, it.label)
}
joined := strings.Join(gotLabels, "\n")
mustContain := []string{
"Spawn agent: claude",
"Spawn process: dev",
"Spawn terminal",
"Spawn process… (custom)",
}
for _, want := range mustContain {
if !strings.Contains(joined, want) {
t.Errorf("missing unified-verb label %q in:\n%s", want, joined)
}
}
// The pre-overhaul verb forms must not appear anywhere.
mustNotContain := []string{"Run process:", "New Terminal", "Spawn process… (custom)"}
for _, bad := range mustNotContain {
if strings.Contains(joined, bad) {
t.Errorf("leftover legacy verb %q present in:\n%s", bad, joined)
}
}
}
func TestPaletteDropsGlobalCloseList(t *testing.T) {
c1 := makeFakeChild("a", "claude", KindAgent)
c2 := makeFakeChild("b", "dev", KindCommand)
p := newPalette([]*Child{c1, c2}, "", "", preset.Set{})
// No focus → no Focused context, so no "kill" / "agent-close" /
// "proc-stop" rows should exist at all.
for _, kind := range []string{"kill", "agent-close", "proc-stop", "proc-delete"} {
if i, _ := findItem(p, kind); i != -1 {
t.Fatalf("kind %q present at %d; global Close list should be gone", kind, i)
}
}
}
// -- Phase 2: section headers and cursor skip ------------------------
func TestPaletteSectionsSeparatedBySpacers(t *testing.T) {
// Section-named dashed headers are gone; groups are visually
// separated by a single non-selectable blank row. Verify that the
// build emits one such spacer between every pair of adjacent groups
// and never a leading spacer.
c := makeFakeChild("a", "claude", KindAgent)
other := makeFakeChild("b", "worker", KindCommand)
p := newPalette([]*Child{c, other}, "a", "",
preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
if len(p.items) == 0 {
t.Fatalf("palette built no items")
}
if p.items[0].action.kind == "header" {
t.Fatalf("first row is a spacer; should be a selectable item")
}
transitions := 0
prevGroup := p.items[0].group
for i := 1; i < len(p.items); i++ {
it := p.items[i]
if it.group != prevGroup {
if it.action.kind != "header" || it.label != "" {
t.Fatalf("group transition at %d not a blank spacer: %+v", i, it)
}
transitions++
// The row immediately after the spacer must be selectable.
if i+1 >= len(p.items) || p.items[i+1].action.kind == "header" {
t.Fatalf("spacer at %d not followed by selectable row", i)
}
prevGroup = p.items[i+1].group
}
// No dashed banners anywhere.
if it.action.kind == "header" && strings.Contains(it.label, "──") {
t.Errorf("dashed section header still present at %d: %q", i, it.label)
}
}
if transitions == 0 {
t.Fatalf("no section transitions found in palette items")
}
}
func TestPaletteCursorSkipsHeaders(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// Initial cursor must land on a selectable row, never a header.
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("initial cursor sits on a header: %+v", p.items[p.cursor])
}
// Walk to the end with cursorDown; every stop must be selectable.
for i := 0; i < len(p.items)*2; i++ {
p.cursorDown()
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("cursorDown landed on a header at index %d", p.cursor)
}
}
// Walk back to top.
for i := 0; i < len(p.items)*2; i++ {
p.cursorUp()
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("cursorUp landed on a header at index %d", p.cursor)
}
}
}
func TestPaletteEnterOnHeaderIsNoOp(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// Force the cursor onto a header.
for i, it := range p.items {
if it.action.kind == "header" {
p.cursor = i
break
}
}
_, done, _ := p.handleInput([]byte("\r"), 0)
if done {
t.Fatalf("Enter on header closed palette; expected no-op")
}
}
// -- Phase 3: filter chips & macro coexistence -----------------------
func TestPaletteTabCyclesChip(t *testing.T) {
p := newTestPalette()
// All → Open
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "sw " {
t.Fatalf("Tab #1: query %q, want %q", string(p.query), "sw ")
}
// Open → Spawn
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "sp " {
t.Fatalf("Tab #2: query %q, want %q", string(p.query), "sp ")
}
// Spawn → Close
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "k " {
t.Fatalf("Tab #3: query %q, want %q", string(p.query), "k ")
}
// Close → All (wraps)
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "" {
t.Fatalf("Tab #4 wrap: query %q, want empty", string(p.query))
}
}
func TestPaletteShiftTabCyclesBackwards(t *testing.T) {
p := newTestPalette()
// Shift-Tab via legacy CSI Z: All → Close
_, _, _ = p.handleInput([]byte("\x1b[Z"), 0)
if string(p.query) != "k " {
t.Fatalf("Shift-Tab: query %q, want %q", string(p.query), "k ")
}
}
func TestPaletteBackspaceThroughTrailingMacro(t *testing.T) {
p := newTestPalette()
p.query = []rune("sw ")
p.rebuild()
p.backspace()
if string(p.query) != "" {
t.Fatalf("backspace through 'sw ' left %q; want empty", string(p.query))
}
}
func TestPaletteMacroPreservesQueryCase(t *testing.T) {
// Tab cycling shouldn't downcase the user-typed search text.
p := newTestPalette()
p.query = []rune("Foo")
p.rebuild()
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "sw Foo" {
t.Fatalf("query after Tab over 'Foo' = %q; want 'sw Foo'", string(p.query))
}
}
// -- Phase 4: scored matching ----------------------------------------
func TestFuzzyScorePrefixBeatsBoundaryBeatsSubstring(t *testing.T) {
prefix, _ := fuzzyScore("spawn agent: foo", "", "spa")
boundary, _ := fuzzyScore("hello spam", "", "spa")
substring, _ := fuzzyScore("escapade", "", "spa")
if !(prefix > boundary && boundary > substring) {
t.Fatalf("score ordering wrong: prefix=%d boundary=%d substring=%d", prefix, boundary, substring)
}
}
func TestFuzzyScoreReturnsMatchPositions(t *testing.T) {
_, pos := fuzzyScore("spawn process: dev", "", "dev")
want := []int{15, 16, 17}
if len(pos) != len(want) {
t.Fatalf("positions = %v, want %v", pos, want)
}
for i, p := range pos {
if p != want[i] {
t.Fatalf("pos[%d] = %d, want %d (full %v)", i, p, want[i], pos)
}
}
}
func TestPaletteScoredResultsDropHeaders(t *testing.T) {
pr := []*preset.Preset{{Name: "claude"}, {Name: "codex"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// Type a needle that matches both.
p.query = []rune("c")
p.rebuild()
for _, it := range p.items {
if it.action.kind == "header" {
t.Fatalf("scored mode should not emit header rows; got %+v", it)
}
}
}
func TestPaletteScoringFloatsPrefixMatchToTop(t *testing.T) {
// "x" is a prefix of "xtest" preset; it's a scattered-fuzzy match
// against many other rows. Scoring should land the prefix match at
// the top regardless of group order.
pr := []*preset.Preset{
{Name: "alpha"},
{Name: "xtest"},
{Name: "beta"},
}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
p.query = []rune("xt")
p.rebuild()
if len(p.items) == 0 {
t.Fatalf("no scored items for needle 'xt'")
}
if !strings.Contains(p.items[0].label, "xtest") {
t.Fatalf("expected xtest at top of scored list, got %q", p.items[0].label)
}
}
// -- Phase 5: power-user accelerators --------------------------------
func TestPaletteCtrlXOnSwitchKills(t *testing.T) {
c := makeFakeChild("a", "claude", KindAgent)
p := newPalette([]*Child{c}, "", "", preset.Set{})
// Cursor should already be on the switch row (it's the first
// selectable item with no Focused section).
idx, _ := findItem(p, "switch")
if idx < 0 {
t.Fatalf("no switch item in palette")
}
p.cursor = idx
action, done, _ := p.handleInput([]byte{0x18}, 0)
if !done {
t.Fatalf("Ctrl-X on switch row didn't close palette: action=%+v", action)
}
if action.kind != "kill" || action.childID != "a" {
t.Fatalf("Ctrl-X action = %+v, want kill of 'a'", action)
}
}
func TestPaletteCtrlXOnNonSwitchIsNoOp(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{})
// Cursor parks on Quit or Spawn entries — neither is a switch row.
_, done, _ := p.handleInput([]byte{0x18}, 0)
if done {
t.Fatalf("Ctrl-X on non-switch closed palette")
}
}
func TestPaletteHelpToggle(t *testing.T) {
p := newTestPalette()
// `?` with empty query opens help.
_, done, _ := p.handleInput([]byte("?"), 0)
if done {
t.Fatalf("? closed palette")
}
if !p.showHelp {
t.Fatalf("? didn't open help")
}
// Next keystroke dismisses.
_, _, _ = p.handleInput([]byte("a"), 0)
if p.showHelp {
t.Fatalf("help still showing after dismissing keystroke")
}
}
func TestPaletteHelpDoesNotInterceptInQuery(t *testing.T) {
p := newTestPalette()
p.query = []rune("dev")
p.rebuild()
_, _, _ = p.handleInput([]byte("?"), 0)
if p.showHelp {
t.Fatalf("? with non-empty query incorrectly opened help")
}
if string(p.query) != "dev?" {
t.Fatalf("? with non-empty query failed to append: %q", string(p.query))
}
}
func TestPaletteHomeEndJumpsOverHeaders(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// End jumps to last selectable.
p.cursorEnd()
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("End landed on header: %+v", p.items[p.cursor])
}
if p.items[p.cursor].action.kind != "quit" {
t.Fatalf("End on simple palette should park on Quit; got %+v", p.items[p.cursor])
}
// Home returns to first selectable.
p.cursorHome()
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("Home landed on header: %+v", p.items[p.cursor])
}
}
func TestPaletteAltDigitQuickPick(t *testing.T) {
pr := []*preset.Preset{{Name: "first"}, {Name: "second"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// Alt-1 picks the first selectable item (Spawn agent: first).
action, done, adv := p.handleInput([]byte("\x1b1"), 0)
if adv != 2 {
t.Fatalf("Alt-1 advance %d, want 2", adv)
}
if !done {
t.Fatalf("Alt-1 didn't close palette")
}
if action.kind != "spawn-agent" || action.preset == nil || action.preset.Name != "first" {
t.Fatalf("Alt-1 action = %+v, want spawn-agent first", action)
}
}
func TestAutoSummaryCadenceCyclesSoloValues(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeAutoSummary
for i, row := range autoSummaryRows() {
if row.key == "cadence" {
p.cursor = i
break
}
}
if p.settings.AutoSummary.Cadence != "1m" {
t.Fatalf("initial cadence = %q", p.settings.AutoSummary.Cadence)
}
action, done, _ := p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("first cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "15s" {
t.Fatalf("first cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
action, done, _ = p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("second cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "30s" {
t.Fatalf("second cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
action, done, _ = p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("third cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "1m" {
t.Fatalf("third cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
}
func TestAutoSummaryScreenOmitsExplicitSaveCancelBackRows(t *testing.T) {
omitted := map[string]bool{
"Save settings": true,
"Cancel": true,
"Back to Settings": true,
}
for _, row := range autoSummaryRows() {
if omitted[row.label] {
t.Fatalf("auto-summary settings should not show %q", row.label)
}
}
}
func TestAutoSummaryRenderOmitsStaleSettingsHelp(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeAutoSummary
var b bytes.Buffer
p.renderAutoSummary(wrapWriter(&b), 100, 30)
out := b.String()
for _, text := range []string{
"Save settings",
"Cancel",
"Back to Settings",
"changes save",
"applies immediately",
} {
if strings.Contains(out, text) {
t.Fatalf("auto-summary render should not contain %q:\n%s", text, out)
}
}
}
func TestAutoSummaryValueRowsStyleLabelAndValueSeparately(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
rows := p.autoSummaryDisplayRows()
for _, row := range rows {
if strings.Contains(row, "Cadence:") {
if !strings.HasPrefix(row, styleHint+"Cadence:"+styleReset+" ") {
t.Fatalf("cadence row styling = %q", row)
}
if strings.Contains(strings.TrimPrefix(row, styleHint+"Cadence:"+styleReset+" "), styleHint) {
t.Fatalf("cadence value should use regular text styling: %q", row)
}
return
}
}
t.Fatal("missing cadence display row")
}
func TestAutoSummaryTextInputSavesWhenSubmitted(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeSettingsInput
p.settingsInput = &settingsInputForm{
title: "codex model",
field: "codex_model",
value: []rune("custom-model"),
}
action, done, _ := p.handleSettingsTextInput([]byte{'\r'}, 0)
if done || action.kind != "settings-save" {
t.Fatalf("submit action = %+v done=%v, want settings-save without close", action, done)
}
if got := p.settings.AutoSummary.modelFor("codex"); got != "custom-model" {
t.Fatalf("codex model = %q", got)
}
}
func TestPaletteFormCtrlRTogglesRelaunchFromCommandField(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
// Type without leaving the command field, then Ctrl-R.
for _, b := range []byte("xyz") {
_, _, _ = p.handleInput([]byte{b}, 0)
}
if p.form.field != 0 {
t.Fatalf("field jumped to %d", p.form.field)
}
_, _, _ = p.handleInput([]byte{0x12}, 0)
if !p.form.relaunch {
t.Fatalf("Ctrl-R didn't toggle relaunch from command field")
}
// Second press toggles back.
_, _, _ = p.handleInput([]byte{0x12}, 0)
if p.form.relaunch {
t.Fatalf("second Ctrl-R didn't toggle off")
}
}
// -- Phase 6: counter / scroll indicator -----------------------------
func TestPaletteFooterCounter(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
total := p.visibleSelectableCount()
if total < 4 { // 3 spawn-agents + terminal + custom + quit
t.Fatalf("expected ≥4 selectables; got %d", total)
}
idx := p.selectableIndex()
if idx <= 0 {
t.Fatalf("selectable index = %d on freshly-built palette; want ≥1", idx)
}
}

View File

@@ -0,0 +1,162 @@
package app
import (
"context"
"syscall"
"testing"
"github.com/hjbdev/patterm/internal/preset"
)
func TestSwitchProjectPreservesProjectProcessTrees(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24)
defer reg.Shutdown()
projectA, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project A: %v", err)
}
projectB, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project B: %v", err)
}
a, err := projectA.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "a-loop",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project A command: %v", err)
}
b, err := projectB.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "b-loop",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project B command: %v", err)
}
t.Cleanup(func() {
_ = projectA.Session.Kill(a.ID, syscall.SIGTERM)
_ = projectB.Session.Kill(b.ID, syscall.SIGTERM)
})
waitUntilLive(t, a)
waitUntilLive(t, b)
st := &uiState{
registry: reg,
project: projectA,
sess: projectA.Session,
launcher: projectA.Launcher,
pads: projectA.Pads,
trust: projectA.Trust,
timers: projectA.Host.timers,
chromeWake: make(chan struct{}, 1),
view: ClientView{
ID: "test",
ProjectKey: projectA.Key,
ProjectName: projectA.Name,
Cols: 80,
Rows: 24,
},
}
st.focusChildLocked(a)
projectA.Session.Subscribe(st)
st.switchProject(projectB)
if st.view.ProjectKey != projectB.Key {
t.Fatalf("view project key = %q, want %q", st.view.ProjectKey, projectB.Key)
}
if st.sess != projectB.Session {
t.Fatalf("ui session did not move to project B")
}
if projectA.Session.FindChild(a.ID) == nil {
t.Fatalf("project A child disappeared after switch")
}
if projectB.Session.FindChild(b.ID) == nil {
t.Fatalf("project B child disappeared after switch")
}
if !a.IsLive() {
t.Fatalf("project A child stopped after switch")
}
if !b.IsLive() {
t.Fatalf("project B child stopped after switch")
}
st.switchProject(projectA)
if st.view.ProjectKey != projectA.Key {
t.Fatalf("view project key after switching back = %q, want %q", st.view.ProjectKey, projectA.Key)
}
if projectA.Session.FindChild(a.ID) == nil || projectB.Session.FindChild(b.ID) == nil {
t.Fatalf("switching back should preserve both project process trees")
}
}
func TestProjectRegistryScratchpadsRouteByCallerProject(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24)
defer reg.Shutdown()
projectA, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project A: %v", err)
}
projectB, err := reg.Open(ctx, t.TempDir())
if err != nil {
t.Fatalf("open project B: %v", err)
}
a, err := projectA.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "a-caller",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project A caller: %v", err)
}
b, err := projectB.Session.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"},
Name: "b-caller",
}, 80, 24)
if err != nil {
t.Fatalf("spawn project B caller: %v", err)
}
t.Cleanup(func() {
_ = projectA.Session.Kill(a.ID, syscall.SIGTERM)
_ = projectB.Session.Kill(b.ID, syscall.SIGTERM)
})
waitUntilLive(t, a)
waitUntilLive(t, b)
if _, err := reg.ScratchpadWrite(a.ID, "note.md", "project A", ""); err != nil {
t.Fatalf("write project A scratchpad: %v", err)
}
if _, err := reg.ScratchpadWrite(b.ID, "note.md", "project B", ""); err != nil {
t.Fatalf("write project B scratchpad: %v", err)
}
gotA, _, err := reg.ScratchpadRead(a.ID, "note.md")
if err != nil {
t.Fatalf("read project A scratchpad: %v", err)
}
gotB, _, err := reg.ScratchpadRead(b.ID, "note.md")
if err != nil {
t.Fatalf("read project B scratchpad: %v", err)
}
if gotA != "project A" || gotB != "project B" {
t.Fatalf("scratchpad routing leaked between projects: A=%q B=%q", gotA, gotB)
}
}

View File

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

View File

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

View File

@@ -46,10 +46,22 @@ type Session struct {
listenersMu sync.Mutex
listeners atomic.Pointer[[]ChildEventListener]
// clientListeners is the network-client subscriber path. These
// listeners must be non-blocking and copy PTY chunks before enqueueing;
// daemon-internal observers (timers, debug capture, waiters) stay on
// listeners above so backpressure policy is isolated to clients.
clientListenersMu sync.Mutex
clientListeners atomic.Pointer[[]ChildEventListener]
// persistStore records top-level command entries to a per-project
// 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 +73,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 +94,16 @@ 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)
// OnChildClosed fires when a child is being removed from the
// session (either via close_process, or — for agent/terminal
// kinds — when the PTY exits and the entry will never be
// restarted). It signals that any pending references to childID
// (e.g. timers owned by or watching it) should be dropped.
OnChildClosed(childID string)
}
func NewSession(projectDir, projectKey string) *Session {
@@ -91,6 +125,16 @@ func (s *Session) Subscribe(l ChildEventListener) {
s.listeners.Store(&next)
}
func (s *Session) SubscribeClient(l ChildEventListener) {
s.clientListenersMu.Lock()
defer s.clientListenersMu.Unlock()
prev := s.clientListenersSnapshot()
next := make([]ChildEventListener, 0, len(prev)+1)
next = append(next, prev...)
next = append(next, l)
s.clientListeners.Store(&next)
}
// Unsubscribe removes a previously-registered listener. Safe to call
// with a listener that wasn't registered (no-op).
func (s *Session) Unsubscribe(l ChildEventListener) {
@@ -109,6 +153,24 @@ func (s *Session) Unsubscribe(l ChildEventListener) {
s.listeners.Store(&next)
}
// UnsubscribeClient removes a previously-registered network client listener.
// Safe to call with a listener that was never registered.
func (s *Session) UnsubscribeClient(l ChildEventListener) {
s.clientListenersMu.Lock()
defer s.clientListenersMu.Unlock()
prev := s.clientListenersSnapshot()
if len(prev) == 0 {
return
}
next := make([]ChildEventListener, 0, len(prev))
for _, e := range prev {
if e != l {
next = append(next, e)
}
}
s.clientListeners.Store(&next)
}
// listenersSnapshot returns the frozen listener slice. Safe to call
// without the listeners mutex.
func (s *Session) listenersSnapshot() []ChildEventListener {
@@ -119,16 +181,30 @@ func (s *Session) listenersSnapshot() []ChildEventListener {
return *p
}
func (s *Session) clientListenersSnapshot() []ChildEventListener {
p := s.clientListeners.Load()
if p == nil {
return nil
}
return *p
}
func (s *Session) emitSpawn(c *Child) {
for _, l := range s.listenersSnapshot() {
l.OnChildSpawned(c)
}
for _, l := range s.clientListenersSnapshot() {
l.OnChildSpawned(c)
}
}
func (s *Session) emitExit(c *Child) {
for _, l := range s.listenersSnapshot() {
l.OnChildExited(c)
}
for _, l := range s.clientListenersSnapshot() {
l.OnChildExited(c)
}
}
// emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners
@@ -138,6 +214,27 @@ func (s *Session) emitPTYOut(id string, chunk []byte) {
for _, l := range s.listenersSnapshot() {
l.OnPTYOut(id, chunk)
}
for _, l := range s.clientListenersSnapshot() {
l.OnPTYOut(id, chunk)
}
}
func (s *Session) emitStateChanged(id string, state IdleState) {
for _, l := range s.listenersSnapshot() {
l.OnChildStateChanged(id, state)
}
for _, l := range s.clientListenersSnapshot() {
l.OnChildStateChanged(id, state)
}
}
func (s *Session) emitClosed(id string) {
for _, l := range s.listenersSnapshot() {
l.OnChildClosed(id)
}
for _, l := range s.clientListenersSnapshot() {
l.OnChildClosed(id)
}
}
func (s *Session) ChildEnv() []string {
@@ -168,6 +265,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
@@ -182,6 +284,9 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
if spec.Env == nil {
spec.Env = s.ChildEnv()
}
if spec.WorkDir == "" {
spec.WorkDir = s.projectDir
}
s.mu.Lock()
id := s.mintUniqueIDLocked()
@@ -198,6 +303,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()
@@ -336,10 +447,29 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
}
}
s.mu.Unlock()
// Notify listeners outside s.mu so they can take their own locks
// without inversion. Timer manager uses this to drop pending
// timers owned by or watching the closed child — otherwise the
// next classifier tick can deliver a stale fire to the parent.
s.emitClosed(id)
s.forgetPersisted(id)
return nil
}
// Terminate stops a live child with SIGTERM/SIGKILL escalation but
// leaves its session entry intact so callers can keep showing the
// exited pane.
func (s *Session) Terminate(id string, sig syscall.Signal) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such process %q", id)
}
if c.IsLive() {
terminateAndWait(c, sig, childStopTimeout)
}
return nil
}
// mintUniqueIDLocked mints an opaque process_id (SPEC §7) and retries
// if it collides with an existing entry. Caller holds s.mu.
func (s *Session) mintUniqueIDLocked() string {
@@ -371,9 +501,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 +562,24 @@ 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()
s.emitClosed(c.ID)
}
}
// killDescendantsOf terminates every still-live direct child of
@@ -565,6 +742,22 @@ func (s *Session) ResizeAll(cols, rows uint16) {
}
}
func (s *Session) ResizeChild(id string, cols, rows uint16) {
if cols == 0 || rows == 0 {
return
}
c := s.FindChild(id)
if c == nil {
return
}
if pty := c.PTY(); pty != nil {
_ = pty.Resize(cols, rows)
}
if em := c.Emulator(); em != nil {
_ = em.Resize(cols, rows)
}
}
// SerializeChild returns the VT bytes that reproduce the child's
// current screen state. Used to repaint a child after the user switches
// focus or closes the palette.
@@ -632,6 +825,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

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

150
internal/app/settings.go Normal file
View File

@@ -0,0 +1,150 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/hjbdev/patterm/internal/preset"
)
const (
defaultSummaryProvider = "codex"
defaultCodexModel = "gpt-5.4-mini"
defaultOpenCodeModel = "opencode-go/minimax-m2.7"
defaultClaudeModel = "claude-haiku-4-5"
)
type settings struct {
AutoSummary autoSummarySettings `json:"auto_summary"`
}
type autoSummarySettings struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Models map[string]string `json:"models"`
Cadence string `json:"cadence"`
QuietWindowMS int `json:"quiet_window_ms"`
MinInputChars int `json:"min_input_chars"`
MaxHistoryChars int `json:"max_history_chars"`
}
func defaultSettings() settings {
return settings{
AutoSummary: autoSummarySettings{
Enabled: true,
Provider: defaultSummaryProvider,
Models: defaultSummaryModels(),
Cadence: "1m",
QuietWindowMS: 3000,
MinInputChars: 4,
MaxHistoryChars: 12000,
},
}
}
func defaultSummaryModels() map[string]string {
return map[string]string{
"codex": defaultCodexModel,
"opencode": defaultOpenCodeModel,
"claude": defaultClaudeModel,
}
}
func loadSettings() (settings, string, error) {
base, err := preset.ConfigDir()
if err != nil {
return settings{}, "", err
}
path := filepath.Join(base, "settings.json")
st := defaultSettings()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return st, path, nil
}
return st, path, fmt.Errorf("settings: read %s: %w", path, err)
}
if err := json.Unmarshal(b, &st); err != nil {
return defaultSettings(), path, fmt.Errorf("settings: parse %s: %w", path, err)
}
st.normalize()
return st, path, nil
}
func saveSettings(path string, st settings) error {
if path == "" {
return fmt.Errorf("settings: empty path")
}
st.normalize()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
return os.WriteFile(path, b, 0o600)
}
func (st *settings) normalize() {
def := defaultSettings()
if st.AutoSummary.Provider == "" {
st.AutoSummary.Provider = def.AutoSummary.Provider
}
switch st.AutoSummary.Provider {
case "codex", "opencode", "claude":
default:
st.AutoSummary.Provider = def.AutoSummary.Provider
}
if st.AutoSummary.Models == nil {
st.AutoSummary.Models = defaultSummaryModels()
} else {
for k, v := range defaultSummaryModels() {
if st.AutoSummary.Models[k] == "" {
st.AutoSummary.Models[k] = v
}
}
}
if st.AutoSummary.Cadence == "" {
st.AutoSummary.Cadence = def.AutoSummary.Cadence
}
if st.AutoSummary.QuietWindowMS <= 0 {
st.AutoSummary.QuietWindowMS = def.AutoSummary.QuietWindowMS
}
if st.AutoSummary.MinInputChars <= 0 {
st.AutoSummary.MinInputChars = def.AutoSummary.MinInputChars
}
if st.AutoSummary.MaxHistoryChars <= 0 {
st.AutoSummary.MaxHistoryChars = def.AutoSummary.MaxHistoryChars
}
}
func (st settings) clone() settings {
st.normalize()
if st.AutoSummary.Models != nil {
models := make(map[string]string, len(st.AutoSummary.Models))
for k, v := range st.AutoSummary.Models {
models[k] = v
}
st.AutoSummary.Models = models
}
return st
}
func (a autoSummarySettings) clone() autoSummarySettings {
st := settings{AutoSummary: a}.clone()
return st.AutoSummary
}
func (a autoSummarySettings) modelFor(provider string) string {
if a.Models == nil {
return defaultSummaryModels()[provider]
}
if m := a.Models[provider]; m != "" {
return m
}
return defaultSummaryModels()[provider]
}

View File

@@ -0,0 +1,72 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestLoadSettingsDefaults(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
st, path, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings: %v", err)
}
if filepath.Base(path) != "settings.json" {
t.Fatalf("settings path = %q", path)
}
if !st.AutoSummary.Enabled {
t.Fatal("auto-summary should default enabled")
}
if st.AutoSummary.Provider != "codex" {
t.Fatalf("provider = %q want codex", st.AutoSummary.Provider)
}
if st.AutoSummary.Cadence != "1m" {
t.Fatalf("cadence = %q want 1m", st.AutoSummary.Cadence)
}
if got := st.AutoSummary.modelFor("codex"); got != "gpt-5.4-mini" {
t.Fatalf("codex model = %q", got)
}
if got := st.AutoSummary.modelFor("opencode"); got != "opencode-go/minimax-m2.7" {
t.Fatalf("opencode model = %q", got)
}
}
func TestSettingsCloneDoesNotShareModelMap(t *testing.T) {
st := defaultSettings()
cp := st.clone()
cp.AutoSummary.Models["codex"] = "changed"
if st.AutoSummary.Models["codex"] == "changed" {
t.Fatal("clone shared Models map with original")
}
a := st.AutoSummary.clone()
a.Models["opencode"] = "changed"
if st.AutoSummary.Models["opencode"] == "changed" {
t.Fatal("autoSummarySettings clone shared Models map with original")
}
}
func TestSaveAndLoadSettings(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
st := defaultSettings()
st.AutoSummary.Provider = "opencode"
st.AutoSummary.Models["opencode"] = "minimax/test"
path := filepath.Join(dir, "patterm", "settings.json")
if err := saveSettings(path, st); err != nil {
t.Fatalf("saveSettings: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("settings file missing: %v", err)
}
got, _, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings: %v", err)
}
if got.AutoSummary.Provider != "opencode" {
t.Fatalf("provider = %q", got.AutoSummary.Provider)
}
if got.AutoSummary.modelFor("opencode") != "minimax/test" {
t.Fatalf("opencode model = %q", got.AutoSummary.modelFor("opencode"))
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"
)
const (
@@ -11,6 +12,146 @@ const (
statusRows = 1
)
// fitName returns name truncated to fit budget visible cells, with a
// trailing "…" when it overflows. Operates on RAW (unstyled) input;
// the caller wraps the result in SGR. Returns "" when budget <= 0.
func fitName(name string, budget int) string {
if budget <= 0 {
return ""
}
runes := []rune(name)
if len(runes) <= budget {
return name
}
if budget == 1 {
return "…"
}
return string(runes[:budget-1]) + "…"
}
// marqueeWindow returns the window of name starting at offset, exactly
// budget cells wide. Pre: caller has decided the name overflows budget
// and offset is in [0, len([]rune(name))-budget]. Operates on RAW
// (unstyled) input.
func marqueeWindow(name string, budget, offset int) string {
if budget <= 0 {
return ""
}
runes := []rune(name)
if len(runes) <= budget {
return name
}
if offset < 0 {
offset = 0
}
end := offset + budget
if end > len(runes) {
end = len(runes)
offset = end - budget
if offset < 0 {
offset = 0
}
}
return string(runes[offset:end])
}
// clampVisible truncates s so that its visible (non-SGR) length is at
// most width cells, preserving any active style by appending a reset.
// Used as a defensive net by write() so a row whose decoration was
// mis-sized still cannot spill past the sidebar band into the PTY area.
func clampVisible(s string, width int) string {
if width <= 0 {
return ""
}
if visibleLen(s) <= width {
return s
}
var b strings.Builder
b.Grow(len(s))
visible := 0
inEsc := false
for _, r := range s {
if inEsc {
b.WriteRune(r)
if r == 'm' || r == 'H' {
inEsc = false
}
continue
}
if r == 0x1b {
inEsc = true
b.WriteRune(r)
continue
}
if visible >= width {
break
}
b.WriteRune(r)
visible++
}
b.WriteString(styleReset)
return b.String()
}
// chooseSidebarSuffix decides whether to keep or drop the trailing
// timer indicator from a sidebar row's suffix. When the row's name
// would have to ellipsise with the timer present, but the budget
// freed by dropping the timer still leaves at least 6 cells for the
// name, the timer is dropped. The name is the only identifier the
// user has for that row; the timer is recoverable from the status
// line and palette.
func chooseSidebarSuffix(nameRuneLen, width int, prefix, suffix, timer string) (string, int) {
prefixCost := visibleLen(prefix)
budget := width - prefixCost - visibleLen(suffix)
if nameRuneLen <= budget || timer == "" {
return suffix, budget
}
slim := strings.TrimSuffix(suffix, timer)
if slim == suffix {
return suffix, budget
}
slimBudget := width - prefixCost - visibleLen(slim)
if slimBudget >= 6 {
return slim, slimBudget
}
return suffix, budget
}
// rowNameSlot returns the unstyled name cell for a sidebar row.
// Unfocused (or focused-and-fitting) rows get fitName with a trailing
// "…" on overflow. The focused row, when its name overflows the
// budget, gets the current marquee window — exactly budget cells
// wide so the surrounding row geometry stays put while it animates.
func (st *uiState) rowNameSlot(id, rawName string, budget int, focused bool) string {
if budget <= 0 {
return ""
}
runes := []rune(rawName)
if !focused || len(runes) <= budget {
return fitName(rawName, budget)
}
off, _, _ := st.marquee.step(id, len(runes), budget, time.Now())
return marqueeWindow(rawName, budget, off)
}
// 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 +160,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
@@ -50,6 +195,9 @@ func (st *uiState) drawSidebar() {
if row > maxRow {
return
}
if visibleLen(content) > width {
content = clampVisible(content, width)
}
pad := width - visibleLen(content)
if pad < 0 {
pad = 0
@@ -62,14 +210,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,
@@ -89,14 +279,19 @@ func (st *uiState) drawSidebar() {
if c.AutoRestart() {
marker = " " + styleDim + "⟳" + styleReset
}
var line string
timer := timerIndicator(c)
var prefix, openStyle string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " +
styleBold + c.DisplayName() + styleReset + marker
prefix = " " + styleAccent + "▎" + styleReset + " " + glyph + " "
openStyle = styleBold
} else {
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker
prefix = " " + glyph + " "
openStyle = styleHint
}
write(line)
raw := c.DisplayName()
suffix, budget := chooseSidebarSuffix(len([]rune(raw)), width, prefix, marker+timer, timer)
nameCell := st.rowNameSlot(c.ID, raw, budget, focused)
write(prefix + openStyle + nameCell + styleReset + suffix)
}
// Agent Tree section — formerly "Session tree". Shows the active
@@ -121,14 +316,29 @@ func (st *uiState) drawSidebar() {
}
focused := c.ID == focus
glyph := statusGlyph(c, focused)
var line string
timer := timerIndicator(c)
var prefix, openStyle string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " +
styleBold + c.DisplayName() + styleReset
prefix = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " "
openStyle = styleBold
} else {
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset
prefix = " " + indent + glyph + " "
openStyle = styleHint
}
raw := c.DisplayName()
suffix, budget := chooseSidebarSuffix(len([]rune(raw)), width, prefix, timer, timer)
nameCell := st.rowNameSlot(c.ID, raw, budget, focused)
write(prefix + openStyle + nameCell + styleReset + suffix)
}
if summary := st.activeSummaryRaw(); summary != "" && row+2 <= maxRow {
write("")
for _, line := range wrapSidebarSummary(summary, width-4) {
if row > maxRow {
break
}
write(" " + styleDim + line + styleReset)
}
write(line)
}
// Scratchpads list — names only. The preview pane used to live
@@ -147,14 +357,18 @@ func (st *uiState) drawSidebar() {
if row > maxRow {
break
}
var line string
if e.Name == focusPad {
line = " " + styleAccent + "▎" + styleReset + " " +
styleBold + e.Name + styleReset
focused := e.Name == focusPad
var prefix, openStyle string
if focused {
prefix = " " + styleAccent + "▎" + styleReset + " "
openStyle = styleBold
} else {
line = " " + styleHint + e.Name + styleReset
prefix = " "
openStyle = styleHint
}
write(line)
budget := width - visibleLen(prefix)
nameCell := st.rowNameSlot("pad:"+e.Name, e.Name, budget, focused)
write(prefix + openStyle + nameCell + styleReset)
}
}
}
@@ -170,13 +384,64 @@ 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.
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
st.outMu.Unlock()
}
func wrapSidebarSummary(s string, width int) []string {
if width < 1 {
width = 1
}
words := strings.Fields(s)
if len(words) == 0 {
return nil
}
var out []string
var cur string
for _, word := range words {
if visibleLen(word) > width {
if cur != "" {
out = append(out, cur)
cur = ""
}
for visibleLen(word) > width {
out = append(out, clipRunes(word, width))
word = string([]rune(word)[width:])
}
if word != "" {
cur = word
}
continue
}
if cur == "" {
cur = word
continue
}
if visibleLen(cur)+1+visibleLen(word) <= width {
cur += " " + word
continue
}
out = append(out, cur)
cur = word
}
if cur != "" {
out = append(out, cur)
}
if len(out) > 3 {
out = out[:3]
}
return out
}

View File

@@ -0,0 +1,46 @@
package app
import (
"testing"
)
// TestOnChildSpawnedAgentChildKeepsFocus verifies that when a child is
// spawned with a ParentID set (i.e. a patterm-managed agent caused the
// spawn over MCP), OnChildSpawned does NOT steal viewport focus from
// the currently focused child.
func TestOnChildSpawnedAgentChildKeepsFocus(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
st := &uiState{sess: sess}
parent := newChildEntry("p_parent", "parent", KindAgent, nil, nil, "", "", "")
st.focusedID = parent.ID
st.focusedName = parent.Name
subAgent := newChildEntry("p_sub", "sub", KindAgent, nil, nil, parent.ID, "", "")
st.OnChildSpawned(subAgent)
if got := st.focusedID; got != parent.ID {
t.Fatalf("agent-initiated spawn should not change focusedID: want %q, got %q", parent.ID, got)
}
if got := st.focusedName; got != parent.Name {
t.Fatalf("focusedName changed: want %q, got %q", parent.Name, got)
}
}
// TestOnChildSpawnedPaletteChildTakesFocus verifies the legacy path is
// preserved: spawns with an empty ParentID (palette, restore, external
// MCP caller) still auto-focus the new child.
func TestOnChildSpawnedPaletteChildTakesFocus(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
st := &uiState{sess: sess}
st.lastExit.Store(-1)
c := newChildEntry("p_new", "newchild", KindAgent, nil, nil, "", "", "")
st.OnChildSpawned(c)
if got := st.focusedID; got != c.ID {
t.Fatalf("palette-initiated spawn should auto-focus: want %q, got %q", c.ID, got)
}
}

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

463
internal/app/summarizer.go Normal file
View File

@@ -0,0 +1,463 @@
package app
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
"time"
"unicode"
"github.com/hjbdev/patterm/internal/preset"
)
const (
summaryTickInterval = time.Second
summaryTimeout = 90 * time.Second
summaryMaxLineCells = 240
)
type summaryState struct {
Text string
State IdleState
UpdatedAt time.Time
Error string
}
type summaryManager struct {
sess *Session
projectDir string
presets preset.Set
settings func() autoSummarySettings
onUpdate func()
onResult func(string, summaryState)
mu sync.Mutex
tracked map[string]bool
entries map[string]*summaryEntry
}
type summaryEntry struct {
armed bool
dirty bool
running bool
lastInputAt time.Time
lastOutputAt time.Time
lastAttemptAt time.Time
lastSummarized int64
state summaryState
}
type summarizerResponse struct {
Summary string `json:"summary"`
State string `json:"state"`
}
func newSummaryManager(sess *Session, projectDir string, presets preset.Set, settingsFn func() autoSummarySettings, onUpdate func(), onResult func(string, summaryState)) *summaryManager {
return &summaryManager{
sess: sess,
projectDir: projectDir,
presets: presets,
settings: settingsFn,
onUpdate: onUpdate,
onResult: onResult,
tracked: make(map[string]bool),
entries: make(map[string]*summaryEntry),
}
}
func (m *summaryManager) run(ctx context.Context) {
ticker := time.NewTicker(summaryTickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.maybeStart(ctx, time.Now())
}
}
}
func (m *summaryManager) ObserveHumanInput(childID string, b []byte) {
if m == nil || !m.isTracked(childID) {
return
}
cfg := m.settings()
if len(strings.TrimSpace(string(b))) < cfg.MinInputChars {
return
}
m.mu.Lock()
e := m.entryLocked(childID)
e.armed = true
e.lastInputAt = time.Now()
m.mu.Unlock()
}
func (m *summaryManager) ObserveOutput(childID string) {
if m == nil || !m.isTracked(childID) {
return
}
m.mu.Lock()
e := m.entryLocked(childID)
if e.armed {
e.dirty = true
e.lastOutputAt = time.Now()
}
m.mu.Unlock()
}
func (m *summaryManager) RegisterChild(c *Child) {
if m == nil || c == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if isTopLevelSummarizedAgent(c) {
m.tracked[c.ID] = true
} else {
delete(m.tracked, c.ID)
}
}
func (m *summaryManager) UnregisterChild(id string) {
if m == nil || id == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
delete(m.tracked, id)
}
func (m *summaryManager) isTracked(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.tracked[id]
}
func (m *summaryManager) Summary(childID string) summaryState {
if m == nil || childID == "" {
return summaryState{}
}
m.mu.Lock()
defer m.mu.Unlock()
if e := m.entries[childID]; e != nil {
return e.state
}
return summaryState{}
}
func (m *summaryManager) RunNow(ctx context.Context, childID string) {
if m == nil || childID == "" {
return
}
c := m.sess.FindChild(childID)
if !isTopLevelSummarizedAgent(c) {
return
}
m.mu.Lock()
e := m.entryLocked(c.ID)
if e.running {
m.mu.Unlock()
return
}
e.running = true
e.lastAttemptAt = time.Now()
m.mu.Unlock()
go m.runOne(ctx, c.ID, true)
}
func (m *summaryManager) Test(ctx context.Context) error {
cfg := m.settings()
return runSummarizerHealth(ctx, cfg, m.projectDir)
}
func (m *summaryManager) entryLocked(id string) *summaryEntry {
e := m.entries[id]
if e == nil {
e = &summaryEntry{}
m.entries[id] = e
}
return e
}
func (m *summaryManager) maybeStart(ctx context.Context, now time.Time) {
cfg := m.settings()
if !cfg.Enabled {
return
}
cadence, err := time.ParseDuration(cfg.Cadence)
if err != nil || cadence <= 0 {
cadence = time.Minute
}
quiet := time.Duration(cfg.QuietWindowMS) * time.Millisecond
var startID string
for _, c := range m.sess.Children() {
if !isTopLevelSummarizedAgent(c) {
continue
}
m.mu.Lock()
e := m.entryLocked(c.ID)
eligible := e.armed && e.dirty && !e.running &&
!e.lastOutputAt.IsZero() && now.Sub(e.lastOutputAt) >= quiet &&
(e.lastAttemptAt.IsZero() || now.Sub(e.lastAttemptAt) >= cadence) &&
c.ScreenVersion() != e.lastSummarized
if eligible {
e.running = true
e.lastAttemptAt = now
startID = c.ID
}
m.mu.Unlock()
if startID != "" {
go m.runOne(ctx, startID, false)
return
}
}
}
func (m *summaryManager) runOne(ctx context.Context, childID string, manual bool) {
c := m.sess.FindChild(childID)
if c == nil {
m.finish(childID, summaryState{Error: "process disappeared"}, 0)
return
}
cfg := m.settings()
snapshot := buildSummarySnapshot(c, cfg.MaxHistoryChars, m.chromeHintsFor(c.PresetRef))
if strings.TrimSpace(snapshot) == "" {
m.finish(childID, summaryState{Error: "empty snapshot"}, c.ScreenVersion())
return
}
runCtx, cancel := context.WithTimeout(ctx, summaryTimeout)
defer cancel()
resp, err := runSummarizer(runCtx, cfg, m.projectDir, snapshot)
st := summaryState{UpdatedAt: time.Now()}
if err != nil {
st.Error = err.Error()
m.finish(childID, st, c.ScreenVersion())
return
}
st.Text = strings.TrimSpace(resp.Summary)
st.State = summaryIdleState(resp.State)
if st.Text == "" {
st.Error = "empty summary"
}
if manual && st.Text != "" && st.State == StateUnknown {
st.State = c.IdleState()
}
m.finish(childID, st, c.ScreenVersion())
}
func (m *summaryManager) finish(childID string, st summaryState, version int64) {
m.mu.Lock()
e := m.entryLocked(childID)
e.running = false
if st.Text != "" || st.Error != "" {
if st.Text == "" && e.state.Text != "" {
st.Text = e.state.Text
st.State = e.state.State
st.UpdatedAt = e.state.UpdatedAt
}
e.state = st
}
if st.Text != "" {
e.armed = false
e.dirty = false
e.lastSummarized = version
}
m.mu.Unlock()
if m.onUpdate != nil {
m.onUpdate()
}
if m.onResult != nil && (st.Text != "" || st.Error != "") {
m.onResult(childID, st)
}
}
func isTopLevelSummarizedAgent(c *Child) bool {
return c != nil && c.Kind == KindAgent && c.ParentID == "" && c.Status() == StatusRunning
}
func (m *summaryManager) chromeHintsFor(presetName string) []string {
if presetName == "" {
return nil
}
for _, p := range m.presets.Agents {
if p.Name == presetName {
return p.ChromeTrimHints
}
}
return nil
}
func buildSummarySnapshot(c *Child, maxChars int, chromeHints []string) string {
if maxChars <= 0 {
maxChars = 12000
}
grid := ""
if em := c.Emulator(); em != nil {
if txt, err := em.PlainText(); err == nil {
grid = compactSummaryText(applyChromeTrim(txt, chromeHints))
}
}
tailBytes := max(maxChars*4, maxChars)
b := c.tailBytes(tailBytes)
history := compactSummaryText(applyChromeTrim(string(stripANSIBytes(nil, b)), chromeHints))
history = tailString(history, maxChars)
var out strings.Builder
if history != "" {
out.WriteString("Recent rendered history:\n")
out.WriteString(history)
out.WriteString("\n\n")
}
if grid != "" && !strings.Contains(history, grid) {
out.WriteString("Current visible grid:\n")
out.WriteString(grid)
}
return tailString(out.String(), maxChars)
}
func compactSummaryText(in string) string {
in = string(stripANSIBytes(nil, []byte(in)))
in = strings.ReplaceAll(in, "\r\n", "\n")
in = strings.ReplaceAll(in, "\r", "\n")
lines := strings.Split(in, "\n")
out := make([]string, 0, len(lines))
blank := false
for _, line := range lines {
line = strings.TrimRightFunc(line, unicode.IsSpace)
line = strings.Map(func(r rune) rune {
if r == '\t' || r == '\n' {
return r
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, line)
line = truncateSummaryLine(line, summaryMaxLineCells)
if strings.TrimSpace(line) == "" {
if blank {
continue
}
blank = true
out = append(out, "")
continue
}
blank = false
out = append(out, line)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func truncateSummaryLine(s string, max int) string {
if max <= 0 || visibleLen(s) <= max {
return s
}
return clipRunes(s, max-1) + "…"
}
func tailString(s string, max int) string {
rs := []rune(s)
if len(rs) <= max {
return s
}
return string(rs[len(rs)-max:])
}
func runSummarizer(ctx context.Context, cfg autoSummarySettings, projectDir, snapshot string) (summarizerResponse, error) {
prompt := summaryPrompt(snapshot)
out, err := runSummarizerCommand(ctx, cfg, projectDir, prompt)
if err != nil {
return summarizerResponse{}, err
}
resp, err := parseSummarizerResponse(out)
if err != nil {
return summarizerResponse{}, err
}
if summaryIdleState(resp.State) == StateUnknown {
return summarizerResponse{}, fmt.Errorf("invalid summary state %q", resp.State)
}
return resp, nil
}
func runSummarizerHealth(ctx context.Context, cfg autoSummarySettings, projectDir string) error {
out, err := runSummarizerCommand(ctx, cfg, projectDir, "Reply with exactly: patterm okay")
if err != nil {
return err
}
if strings.TrimSpace(out) != "patterm okay" {
return fmt.Errorf("health check did not return patterm okay")
}
return nil
}
func runSummarizerCommand(ctx context.Context, cfg autoSummarySettings, projectDir, prompt string) (string, error) {
provider := cfg.Provider
model := cfg.modelFor(provider)
var cmd *exec.Cmd
switch provider {
case "opencode":
cmd = exec.CommandContext(ctx, "opencode", "run", "--model", model, "--dir", projectDir, prompt)
case "claude":
cmd = exec.CommandContext(ctx, "claude", "--print", "--model", model, prompt)
default:
cmd = exec.CommandContext(ctx, "codex", "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--model", model, "-")
cmd.Stdin = strings.NewReader(prompt)
}
cmd.Dir = projectDir
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s summarizer: %s", provider, msg)
}
return string(out), nil
}
func summaryPrompt(snapshot string) string {
return "Summarize this terminal/agent snapshot for a compact UI catch-up aid.\n" +
"Return only JSON with keys summary and state. State must be one of IDLE, PERMISSION, THINKING, WORKING, ERROR.\n" +
"Keep summary under 180 characters, concrete, and avoid mentioning that you are summarizing.\n\n" +
snapshot
}
func parseSummarizerResponse(out string) (summarizerResponse, error) {
var resp summarizerResponse
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &resp); err == nil {
return resp, nil
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "{") || !strings.HasSuffix(line, "}") {
continue
}
if err := json.Unmarshal([]byte(line), &resp); err == nil {
return resp, nil
}
}
return resp, fmt.Errorf("summary output was not JSON")
}
func summaryIdleState(s string) IdleState {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "IDLE":
return StateIdle
case "PERMISSION":
return StatePermission
case "THINKING":
return StateThinking
case "WORKING":
return StateWorking
case "ERROR":
return StateError
default:
return StateUnknown
}
}

View File

@@ -0,0 +1,125 @@
package app
import (
"strings"
"testing"
"github.com/hjbdev/patterm/internal/preset"
)
func TestParseSummarizerResponseAllowsWrappedJSON(t *testing.T) {
resp, err := parseSummarizerResponse("log\n{\"summary\":\"Waiting for tests\",\"state\":\"WORKING\"}\n")
if err != nil {
t.Fatalf("parseSummarizerResponse: %v", err)
}
if resp.Summary != "Waiting for tests" || summaryIdleState(resp.State) != StateWorking {
t.Fatalf("response = %+v", resp)
}
}
func TestCompactSummaryTextDropsControlAndRedundantWhitespace(t *testing.T) {
got := compactSummaryText("hello\x00 world \n\n\n\x1b[31mred\x1b[0m\n")
if strings.ContainsRune(got, '\x00') {
t.Fatalf("control byte survived: %q", got)
}
if strings.Contains(got, "\n\n\n") {
t.Fatalf("redundant blanks survived: %q", got)
}
if strings.Contains(got, "\x1b") {
t.Fatalf("ansi survived: %q", got)
}
}
func TestWrapSidebarSummaryKeepsWordBoundaries(t *testing.T) {
got := wrapSidebarSummary("alpha beta gamma delta", 12)
want := []string{"alpha beta", "gamma delta"}
if len(got) != len(want) {
t.Fatalf("lines = %#v", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("line %d = %q want %q", i, got[i], want[i])
}
}
long := wrapSidebarSummary("supercalifragilistic short", 8)
if len(long) == 0 || strings.Contains(strings.Join(long, ""), "…") {
t.Fatalf("long word should wrap without ellipsis: %#v", long)
}
for _, line := range long {
if visibleLen(line) > 8 {
t.Fatalf("line %q exceeds width", line)
}
}
}
func TestSummaryTextForSelectsChildAndClips(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
cfg := defaultSettings()
st := &uiState{
sess: sess,
settings: cfg,
summaries: newSummaryManager(sess, t.TempDir(), preset.Set{}, func() autoSummarySettings {
return cfg.AutoSummary.clone()
}, nil, nil),
}
st.summaries.mu.Lock()
st.summaries.entries["a1"] = &summaryEntry{state: summaryState{Text: " alpha summary "}}
st.summaries.entries["a2"] = &summaryEntry{state: summaryState{Text: "beta summary"}}
st.summaries.entries["empty"] = &summaryEntry{state: summaryState{Text: " "}}
st.summaries.entries["long"] = &summaryEntry{state: summaryState{Text: "abcdefghijklmnopqrstuvwxyz"}}
st.summaries.mu.Unlock()
if got := st.summaryTextFor("a2", 20); got != "beta summary" {
t.Fatalf("summaryTextFor(a2) = %q, want beta summary", got)
}
if got := st.summaryTextFor("empty", 20); got != "" {
t.Fatalf("summaryTextFor(empty) = %q, want empty", got)
}
if got := st.summaryTextFor("long", 8); got != "abcdefg…" {
t.Fatalf("summaryTextFor(long) = %q, want abcdefg…", got)
}
st.settingsMu.Lock()
st.settings.AutoSummary.Enabled = false
st.settingsMu.Unlock()
if got := st.summaryTextFor("a1", 20); got != "" {
t.Fatalf("summaryTextFor disabled = %q, want empty", got)
}
}
func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "")
running := StatusRunning
c.status.Store(&running)
sess.children[c.ID] = c
sess.order = append(sess.order, c.ID)
cfg := defaultSettings().AutoSummary
m := newSummaryManager(sess, t.TempDir(), preset.Set{}, func() autoSummarySettings {
return cfg.clone()
}, nil, nil)
m.ObserveHumanInput(c.ID, []byte("please summarize"))
if got := m.Summary(c.ID); got.Text != "" {
t.Fatalf("untracked agent should not update summary state: %+v", got)
}
m.RegisterChild(c)
m.ObserveHumanInput(c.ID, []byte("please summarize"))
m.ObserveOutput(c.ID)
m.mu.Lock()
e := m.entries[c.ID]
m.mu.Unlock()
if e == nil || !e.armed || !e.dirty {
t.Fatalf("tracked top-level agent not armed/dirty: %+v", e)
}
sub := newChildEntry("a2", "sub", KindAgent, []string{"fake"}, nil, c.ID, "", "")
sub.status.Store(&running)
m.RegisterChild(sub)
m.ObserveHumanInput(sub.ID, []byte("please summarize"))
m.mu.Lock()
_, ok := m.entries[sub.ID]
m.mu.Unlock()
if ok {
t.Fatal("sub-agent should not get a summary entry")
}
}

View File

@@ -4,12 +4,13 @@ import (
"fmt"
"os"
"strings"
"time"
"unicode/utf8"
)
// Two-row tab bar: labels row, underline row. The PTY viewport's top
// Three-row tab bar: labels row, active-thread summary row, underline row. The PTY viewport's top
// row is therefore mainTop == tabBarRows + 1.
const tabBarRows = 2
const tabBarRows = 3
// drawTabBar renders the top tab strip across the full host width.
// Tabs share the available width with a flex layout — each visible
@@ -17,9 +18,17 @@ 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
// Highlight the top-level agent tab even when focus has stepped
// into a sub-agent (or a Processes pane entry). activeAgentID walks
// the parent chain to the root, so the user always sees which tab
// their current thread belongs to.
focus := st.activeAgentID
st.mu.Unlock()
if palOpen {
return
@@ -50,12 +59,14 @@ func (st *uiState) drawTabBar() {
newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing
type tabRect struct {
startCol int
width int
label string
active bool
childID string
startCol int
width int
label string
glyph string
glyphStyle string
active bool
}
// Reserve space at the right edge for "+ new". If there are too
// many tabs to fit even at minTabWidth, drop tabs from the right
// until they do. The current focus stays visible.
@@ -105,9 +116,16 @@ func (st *uiState) drawTabBar() {
if i < extra {
w++
}
active := c.ID == focus
glyph, glyphStyle := tabIdleGlyph(c.IdleState(), active)
label := c.DisplayName()
labelW := utf8.RuneCountInString(label)
maxLabelW := w - 2 // one pad on each side
// Reserve room for the glyph + its trailing space when present
// (1 + 1 runes), on top of the one-cell pad on each side.
maxLabelW := w - 2
if glyph != "" {
maxLabelW -= 2
}
if maxLabelW < 1 {
maxLabelW = 1
}
@@ -120,17 +138,21 @@ func (st *uiState) drawTabBar() {
labelW = utf8.RuneCountInString(label)
}
tabs = append(tabs, tabRect{
startCol: col,
width: w,
label: label,
active: c.ID == focus,
childID: c.ID,
startCol: col,
width: w,
label: label,
glyph: glyph,
glyphStyle: glyphStyle,
active: active,
})
col += w
}
}
var b strings.Builder
// Clear both rows so a stale label from the previous frame can't
// Clear all tab-bar rows so stale labels or summaries from the
// previous frame can't
// bleed through. Use ECH clamped to `width` (= childCols) instead of
// `\x1b[2K`: 2K wipes the entire line including the sidebar columns,
// and if drawSidebar's chrome cache is fresh it won't repaint to
@@ -138,32 +160,47 @@ func (st *uiState) drawTabBar() {
// and content should be.
fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX", width)
fmt.Fprintf(&b, "\x1b[2;1H\x1b[%dX", width)
fmt.Fprintf(&b, "\x1b[3;1H\x1b[%dX", width)
for _, t := range tabs {
// Row 1: centre-ish label inside the tab cell.
// Row 1: centre-ish glyph+label inside the tab cell.
labelW := utf8.RuneCountInString(t.label)
leftPad := (t.width - labelW) / 2
visibleW := labelW
if t.glyph != "" {
visibleW += 2 // glyph + separator space
}
leftPad := (t.width - visibleW) / 2
if leftPad < 1 {
leftPad = 1
}
rightPad := t.width - labelW - leftPad
rightPad := t.width - visibleW - leftPad
if rightPad < 0 {
rightPad = 0
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
cellStyle := styleHint
if t.active {
b.WriteString(styleActive)
} else {
b.WriteString(styleHint)
cellStyle = styleActive
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
b.WriteString(cellStyle)
b.WriteString(strings.Repeat(" ", leftPad))
if t.glyph != "" {
// Glyph uses its own colour so error/permission states pop
// regardless of tab focus, matching the sidebar's vocabulary.
b.WriteString(styleReset)
b.WriteString(t.glyphStyle)
b.WriteString(t.glyph)
b.WriteString(styleReset)
b.WriteString(cellStyle)
b.WriteString(" ")
}
b.WriteString(t.label)
b.WriteString(strings.Repeat(" ", rightPad))
b.WriteString(styleReset)
// Row 2: underline. Thick accent for the active tab, faint
// Row 3: underline. Thick accent for the active tab, faint
// border for the rest.
fmt.Fprintf(&b, "\x1b[2;%dH", t.startCol)
fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol)
if t.active {
b.WriteString(styleAccent)
b.WriteString(strings.Repeat("━", t.width))
@@ -180,20 +217,59 @@ func (st *uiState) drawTabBar() {
fmt.Fprintf(&b, "\x1b[1;%dH %s%s%s ", hintCol, styleDim, newHint, styleReset)
// Underline continues faintly under the hint so the strip
// reads as one bar.
fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s",
fmt.Fprintf(&b, "\x1b[3;%dH%s%s%s",
hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset)
}
for _, tab := range tabs {
summaryWidth := tab.width - 2
if summary := st.summaryTextFor(tab.childID, summaryWidth); summary != "" {
fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset)
}
}
frame := b.String()
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()
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
}
// tabIdleGlyph returns the one-rune state indicator (and its SGR style)
// to render before a tab's label. Mirrors the sidebar's vocabulary so
// users learn the symbols in one place: ✕ error, ? permission, ◐
// thinking, ○ idle, ● working. Returns ("", "") for StateUnknown so the
// first frame after spawn doesn't show a misleading badge.
func tabIdleGlyph(state IdleState, active bool) (string, string) {
base := styleHint
if active {
base = styleAccent
}
switch state {
case StateError:
return "✕", styleError
case StatePermission:
return "?", styleAccent
case StateThinking:
return "◐", base
case StateIdle:
return "○", base
case StateWorking:
return "●", base
default:
return "", ""
}
}

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

@@ -0,0 +1,686 @@
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
changes chan struct{}
// 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),
changes: make(chan struct{}, 1),
}
m.fireFn = defaultFireFn
return m
}
func (m *timerManager) changeEvents() <-chan struct{} {
return m.changes
}
func (m *timerManager) notifyChanged() {
select {
case m.changes <- struct{}{}:
default:
}
}
func defaultFireFn(owner *Child, body, label string) {
if owner == nil || !owner.IsLive() {
return
}
// 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) })
m.notifyChanged()
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.notifyChanged()
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()
m.notifyChanged()
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.notifyChanged()
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()
if len(firedIDs) > 0 {
m.notifyChanged()
}
for _, f := range fires {
m.fireFn(f.owner, f.body, f.label)
}
}
// onChildClosed drops pending timer references to childID. Called
// from Session.Close (and the terminal-corpse cleanup in reapChild)
// via the session listener bus — a deliberate signal from the host
// that childID is gone and the parent is not waiting on it anymore.
//
// Semantics:
// - timers owned by childID are cancelled and deleted: their owner
// is gone, so even if defaultFireFn's IsLive guard would no-op
// the delivery, the entry has no business surviving a close.
// - timers watching childID have childID pruned from t.watched
// (and t.idleBaseline). If t.watched becomes empty the timer is
// cancelled and deleted; we deliberately do NOT synthesise a
// fire here. The parent already received any legitimate idle
// transition before close_process — see allWatchedIdleLocked's
// "treat as satisfied" comment, which only applies to a
// concurrent re-evaluation, not to this explicit-removal hook.
//
// The natural-exit path (reapChild → emitExit for agent/command
// kinds) is NOT routed through here: the classifier emits a final
// idle transition on exit, which fires and deletes any watching
// timers exactly once. Cancelling on exit would swallow that
// legitimate fire and leave the parent never notified.
func (m *timerManager) onChildClosed(childID string) {
m.mu.Lock()
changed := false
for id, t := range m.timers {
if t.ownerID == childID {
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
changed = true
continue
}
if !contains(t.watched, childID) {
continue
}
pruned := t.watched[:0]
for _, w := range t.watched {
if w != childID {
pruned = append(pruned, w)
}
}
t.watched = pruned
if t.idleBaseline != nil {
delete(t.idleBaseline, childID)
}
changed = true
if len(t.watched) == 0 {
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
}
}
m.mu.Unlock()
if changed {
m.notifyChanged()
}
}
// allWatchedIdleLocked reports whether every watched child is now
// idle. Called with m.mu held — uses live Child.IdleState() under the
// child's own atomic, not under m.mu.
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()
t, ok := m.timers[id]
if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
}
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status == timerStatusFired || t.status == timerStatusCanceled {
// Cancelling a fired/cancelled timer is idempotent.
m.mu.Unlock()
return nil
}
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
m.mu.Unlock()
m.notifyChanged()
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()
t, ok := m.timers[id]
if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
}
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status != timerStatusPending {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
}
if t.rt != nil {
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
m.mu.Unlock()
m.notifyChanged()
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()
m.notifyChanged()
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
}
const (
timerSidebarMinRefresh = 50 * time.Millisecond
timerSidebarSubsecondRefresh = 100 * time.Millisecond
)
func nextTimerSidebarLabelChange(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
if d < time.Second {
if d < timerSidebarSubsecondRefresh {
return d
}
return timerSidebarSubsecondRefresh
}
step := time.Second
if d >= time.Hour {
step = time.Hour
} else if d >= time.Minute {
step = time.Minute
}
wait := d % step
if wait <= 0 || wait < timerSidebarMinRefresh {
return timerSidebarMinRefresh
}
return wait
}
func (m *timerManager) nextSidebarRefreshAfter(now time.Time) (time.Duration, bool) {
m.mu.Lock()
defer m.mu.Unlock()
var best time.Duration
found := false
for _, t := range m.timers {
if t.status != timerStatusPending || t.firesAt.IsZero() {
continue
}
wait := nextTimerSidebarLabelChange(t.firesAt.Sub(now))
if wait <= 0 {
wait = timerSidebarMinRefresh
}
if !found || wait < best {
best = wait
found = true
}
}
return best, found
}
func isIdleState(s IdleState) bool {
return s == StateIdle
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}

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

@@ -0,0 +1,675 @@
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 waitTimerChange(t *testing.T, mgr *timerManager) {
t.Helper()
select {
case <-mgr.changeEvents():
case <-time.After(time.Second):
t.Fatal("timed out waiting for timer change signal")
}
}
func TestNextTimerSidebarLabelChange(t *testing.T) {
tests := []struct {
name string
d time.Duration
want time.Duration
}{
{name: "minutes", d: 2*time.Minute + 10*time.Second, want: 10 * time.Second},
{name: "minute_to_seconds", d: time.Minute + 500*time.Millisecond, want: 500 * time.Millisecond},
{name: "seconds", d: 59*time.Second + 500*time.Millisecond, want: 500 * time.Millisecond},
{name: "subsecond", d: 500 * time.Millisecond, want: timerSidebarSubsecondRefresh},
{name: "nearly_done", d: 30 * time.Millisecond, want: 30 * time.Millisecond},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextTimerSidebarLabelChange(tt.d); got != tt.want {
t.Fatalf("nextTimerSidebarLabelChange(%s) = %s, want %s", tt.d, got, tt.want)
}
})
}
}
func TestTimerSidebarRefreshAfterUsesSoonestActiveBoundary(t *testing.T) {
_, mgr, _ := newTestManager(t)
now := time.Unix(123, 0)
mgr.mu.Lock()
mgr.timers["slow"] = &pendingTimer{
id: "slow",
status: timerStatusPending,
firesAt: now.Add(2*time.Minute + 10*time.Second),
}
mgr.timers["fast"] = &pendingTimer{
id: "fast",
status: timerStatusPending,
firesAt: now.Add(59*time.Second + 500*time.Millisecond),
}
mgr.timers["paused"] = &pendingTimer{
id: "paused",
status: timerStatusPaused,
firesAt: now.Add(100 * time.Millisecond),
}
mgr.mu.Unlock()
got, ok := mgr.nextSidebarRefreshAfter(now)
if !ok {
t.Fatal("nextSidebarRefreshAfter did not find active timers")
}
if got != 500*time.Millisecond {
t.Fatalf("nextSidebarRefreshAfter = %s, want 500ms", got)
}
}
func TestTimerManagerSignalsChangesForSidebar(t *testing.T) {
sess, mgr, _ := newTestManager(t)
owner := fakeChild("p_owner")
addChild(sess, owner)
id, err := mgr.TimerSet("p_owner", "x", "", 60)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerPause("p_owner", id); err != nil {
t.Fatalf("TimerPause: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerResume("p_owner", id); err != nil {
t.Fatalf("TimerResume: %v", err)
}
waitTimerChange(t, mgr)
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("TimerCancel: %v", err)
}
waitTimerChange(t, mgr)
}
func TestTimerSetDelivers(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
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)
}
}
// TestTimerCloseChildPrunesWatched covers the happy partial-prune
// case: an idle_any timer watches two children, one is closed, the
// timer stays pending and the remaining child can still satisfy it.
func TestTimerCloseChildPrunesWatched(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
working := StateWorking
a.idleState.Store(&working)
b.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_a")
mgr.mu.Lock()
t1, ok := mgr.timers[resp.ID]
if !ok {
mgr.mu.Unlock()
t.Fatalf("timer was removed but still has live watched")
}
watched := append([]string(nil), t1.watched...)
mgr.mu.Unlock()
if len(watched) != 1 || watched[0] != "p_b" {
t.Fatalf("watched after close: %v, want [p_b]", watched)
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("close synthesised a fire: %+v", got)
}
// p_b can still satisfy the timer.
idle := StateIdle
b.idleState.Store(&idle)
mgr.onChildStateChanged("p_b", StateIdle)
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("post-prune fire: %+v", got)
}
}
// TestTimerCloseLastWatchedCancels is the regression for the
// reported stale-fire symptom: the only watched child is closed,
// so the timer must be cancelled — no synthetic fire, and the
// registry entry must be gone so a trailing classifier tick for the
// removed child cannot re-deliver later.
func TestTimerCloseLastWatchedCancels(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "stale body", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_a")
mgr.mu.Lock()
_, stillThere := mgr.timers[resp.ID]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("timer with no remaining watched should be removed")
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("close synthesised a fire: %+v", got)
}
// Simulate the trailing classifier tick for the now-closed child —
// must not fire.
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("trailing state change re-fired: %+v", got)
}
}
// TestTimerCloseChildIdleAllPartialPrune mirrors the idle_any
// partial-prune for idle_all: pruning a watched child shrinks the
// list; the remaining child going idle then satisfies the timer.
func TestTimerCloseChildIdleAllPartialPrune(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
working := StateWorking
a.idleState.Store(&working)
b.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAll: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
mgr.onChildClosed("p_a")
idle := StateIdle
b.idleState.Store(&idle)
mgr.onChildStateChanged("p_b", StateIdle)
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("idle_all after partial prune: %+v", got)
}
}
// TestTimerCloseOwnerCancelsDelay ensures a delay timer is dropped
// when its owner is closed: no delivery, registry empty, the
// underlying time.Timer is stopped.
func TestTimerCloseOwnerCancelsDelay(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "x", "", 0.1)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
mgr.onChildClosed("p_owner")
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("delay timer was not removed when owner closed")
}
time.Sleep(200 * time.Millisecond) // past the original firesAt
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("delay timer fired after owner close: %+v", got)
}
}
// TestTimerCloseWatchedSubAgent is the exact shape of the reported
// stale-fire bug: orchestrator registers a watcher on a sub-agent,
// the sub-agent is closed, and the orchestrator must receive
// nothing (no stale body delivered after close_process).
func TestTimerCloseWatchedSubAgent(t *testing.T) {
sess, mgr, rec := newTestManager(t)
parent := fakeChild("p_owner")
sub := fakeChild("p_sub")
addChild(sess, parent)
addChild(sess, sub)
working := StateWorking
sub.idleState.Store(&working)
if _, err := mgr.TimerFireWhenIdleAny(
"p_owner",
"codex-review-591 finished. Read your own pane …",
"", []string{"p_sub"}, 0,
); err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_sub")
// Trailing classifier emission for the closed sub-agent must
// not deliver anything to the parent.
mgr.onChildStateChanged("p_sub", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("stale fire delivered to parent after sub-agent close: %+v", got)
}
}

361
internal/app/toast.go Normal file
View File

@@ -0,0 +1,361 @@
package app
import (
"os"
"strings"
"sync"
)
// toastKind classifies a toast for styling and for migrating the
// pre-existing flashError / flashTransient / notifyAttention call
// sites onto the new stack.
type toastKind int
const (
toastInfo toastKind = iota
toastError
toastAttention
)
// toast is one entry in the host-level notification stack. Toasts
// persist until the user dismisses them with Ctrl-N or the
// "Clear notifications" palette command — there's no auto-expiry.
type toast struct {
id uint64
kind toastKind
text string
}
// toastStackCap caps how many toasts can be visible at once.
// Older entries drop off the bottom when a new push would exceed it.
const toastStackCap = 5
// toastBoxMaxWidth bounds the rendered box width so a wide pane
// doesn't produce huge toasts. Boxes shrink below this when the pane
// is narrow.
const toastBoxMaxWidth = 50
// toastBoxMinWidth is the floor below which we refuse to render —
// any narrower and there's not enough room for borders + content.
const toastBoxMinWidth = 20
// toastContentRows is how many lines of message body each toast box
// reserves. The dismiss hint lives on the host status strip, so the
// box itself is purely the message.
const toastContentRows = 3
// toastStack owns the ordered list of live toasts. Oldest at
// index 0, newest (visually topmost) at the end. The stack's own
// mutex is intentionally separate from uiState.mu so push / dismiss
// can be called from any goroutine without participating in the
// host's bigger lock-ordering rules.
type toastStack struct {
mu sync.Mutex
items []toast
next uint64
}
func (s *toastStack) push(kind toastKind, text string) {
s.mu.Lock()
defer s.mu.Unlock()
s.next++
s.items = append(s.items, toast{id: s.next, kind: kind, text: text})
if len(s.items) > toastStackCap {
s.items = s.items[len(s.items)-toastStackCap:]
}
}
// dismissTop pops the most recent toast (the one rendered at the
// top of the stack). Returns true if something was removed so
// callers can decide whether to repaint.
func (s *toastStack) dismissTop() bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
return false
}
s.items = s.items[:len(s.items)-1]
return true
}
func (s *toastStack) clear() bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
return false
}
s.items = s.items[:0]
return true
}
func (s *toastStack) snapshot() []toast {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
return nil
}
out := make([]toast, len(s.items))
copy(out, s.items)
return out
}
func (s *toastStack) length() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.items)
}
// notifyToast is the single entry point that the former flash
// helpers now delegate to. It pushes onto the stack and triggers a
// repaint of the focused surface so the new toast appears
// immediately; the repaint path also re-renders the stack on top.
func (st *uiState) notifyToast(kind toastKind, text string) {
st.toasts.push(kind, text)
st.refreshToastSurface()
}
// refreshToastSurface re-renders whatever surface the toasts are
// drawn over (focused child, focused pad, or the empty-state
// canvas). Each of those paths calls renderToasts at the end, so
// the toast layer is always reapplied on top of a freshly-drawn
// pane. Centralised so push / dismiss / clear share one code path.
//
// The status strip also gains/loses the "Ctrl-N · dismiss" hint as
// the stack toggles between empty and non-empty, so we redraw it
// here too rather than waiting for the chrome ticker.
func (st *uiState) refreshToastSurface() {
st.mu.Lock()
focusedPad := st.focusedPad
focusedID := st.focusedID
palOpen := st.palette != nil
st.mu.Unlock()
if palOpen {
// Palette owns the whole screen while it's open; toasts will
// repaint via closePalette's restore path.
return
}
switch {
case focusedPad != "":
st.repaintFocusedPad()
case focusedID != "":
st.repaintFocused()
default:
st.renderEmptyState()
}
st.drawStatusLine()
}
// renderToasts draws the toast stack over the top-right of the
// focused pane. Called from repaintFocused / repaintFocusedPad /
// renderEmptyState after they finish so toasts always sit on top of
// freshly-redrawn pane content. Safe to call when the stack is
// empty (no-op).
func (st *uiState) renderToasts() {
bytes := st.toastOverlayBytes()
if len(bytes) == 0 {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(bytes)
}
// toastOverlayBytes builds the toast layer as a single byte buffer
// without writing to stdout. Returns nil when the stack is empty or
// the layout can't accommodate a box. Callers either write it
// directly (renderToasts) or stitch it onto the end of another
// stdout write so claude/codex/opencode redraws that paint over the
// top-right region can't leave the toast half-erased.
func (st *uiState) toastOverlayBytes() []byte {
items := st.toasts.snapshot()
if len(items) == 0 {
return nil
}
st.mu.Lock()
palOpen := st.palette != nil
st.mu.Unlock()
if palOpen {
return nil
}
layout := st.layoutSnapshot()
paneCols := int(layout.childCols())
paneRows := int(layout.childRows())
if paneCols < toastBoxMinWidth+2 || paneRows < toastContentRows+2 {
return nil
}
boxWidth := toastBoxMaxWidth
if max := paneCols - 4; max < boxWidth {
boxWidth = max
}
if boxWidth < toastBoxMinWidth {
return nil
}
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
// Reserve two columns for the icon prefix on row 1 so wrapped rows
// indent under the body text rather than under the glyph.
const iconCols = 2
bodyRoom := contentWidth - iconCols
if bodyRoom < 1 {
return nil
}
var b strings.Builder
// Wrap the whole overlay in DECSET 2026 (synchronized output)
// brackets so terminals that support BSU/ESU buffer the box paint
// into a single frame — without this, claude's continuous redraws
// and our overlay race on each cell, producing visible flicker.
// Terminals that don't recognise 2026 ignore the brackets, so the
// fallback behaviour is the same as before.
b.WriteString("\x1b[?2026h\x1b7\x1b[?25l")
row := int(layout.mainTop) + 1
col := int(layout.mainLeft) + paneCols - boxWidth - 1
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
// Render newest first (visually on top), iterating items in
// reverse so the most recent push lands at the smallest row.
for idx := len(items) - 1; idx >= 0; idx-- {
t := items[idx]
height := toastContentRows + 2
// Stop if we'd run off the bottom of the pane.
if row+height > int(layout.mainTop)+paneRows {
break
}
border := toastBorderStyle(t.kind)
wrapped := wrapToastBody(t.text, bodyRoom)
// Top border.
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("╭")
b.WriteString(strings.Repeat("─", boxWidth-2))
b.WriteString("╮")
b.WriteString(styleReset)
row++
// Content rows. Row 0 carries the kind glyph; rows 1..N indent
// by iconCols spaces so wrapped text lines up under the body.
for i := 0; i < toastContentRows; i++ {
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
b.WriteString(" ")
if i == 0 {
b.WriteString(toastIcon(t.kind))
} else {
b.WriteString(strings.Repeat(" ", iconCols))
}
line := wrapped[i]
b.WriteString(line)
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(line))))
b.WriteString(" ")
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
row++
}
// Bottom border.
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("╰")
b.WriteString(strings.Repeat("─", boxWidth-2))
b.WriteString("╯")
b.WriteString(styleReset)
row++
// 1-row gap between stacked toasts.
row++
}
b.WriteString("\x1b[?25h\x1b8\x1b[?2026l")
return []byte(b.String())
}
func toastBorderStyle(kind toastKind) string {
switch kind {
case toastError:
return styleError
case toastAttention:
return styleAccent
default:
return styleBorder
}
}
// wrapToastBody word-wraps text into exactly toastContentRows lines,
// each at most width visible runes wide. Short messages are padded
// with empty trailing lines so callers can iterate a fixed-size
// slice; messages that don't fit get ellipsized on the last line.
func wrapToastBody(text string, width int) []string {
out := make([]string, toastContentRows)
if width < 1 {
return out
}
all := wrapToastWords(text, width)
if len(all) > toastContentRows {
all = all[:toastContentRows]
last := all[len(all)-1]
if visibleLen(last) >= width {
last = clipRunes(last, width-1) + "…"
} else {
last = last + "…"
}
all[len(all)-1] = last
}
for i, l := range all {
out[i] = l
}
return out
}
// wrapToastWords is a small word-wrapper sized for toast bodies:
// greedy, breaks overlong words on rune boundaries, drops collapsing
// whitespace via strings.Fields.
func wrapToastWords(text string, width int) []string {
var lines []string
var cur string
flush := func() {
if cur != "" {
lines = append(lines, cur)
cur = ""
}
}
for _, word := range strings.Fields(text) {
for visibleLen(word) > width {
flush()
head := clipRunes(word, width)
lines = append(lines, head)
word = word[len(head):]
}
if word == "" {
continue
}
if cur == "" {
cur = word
continue
}
if visibleLen(cur)+1+visibleLen(word) <= width {
cur += " " + word
continue
}
flush()
cur = word
}
flush()
return lines
}
func toastIcon(kind toastKind) string {
switch kind {
case toastError:
return styleError + "✗ " + styleReset
case toastAttention:
return styleAccent + "! " + styleReset
default:
return styleHint + "• " + styleReset
}
}

164
internal/app/toast_test.go Normal file
View File

@@ -0,0 +1,164 @@
package app
import (
"strings"
"testing"
)
func TestToastStackPushAndOrder(t *testing.T) {
var s toastStack
s.push(toastInfo, "one")
s.push(toastError, "two")
s.push(toastAttention, "three")
snap := s.snapshot()
if len(snap) != 3 {
t.Fatalf("snapshot len = %d, want 3", len(snap))
}
if snap[0].text != "one" || snap[1].text != "two" || snap[2].text != "three" {
t.Fatalf("snapshot order wrong: %#v", snap)
}
if snap[0].kind != toastInfo || snap[1].kind != toastError || snap[2].kind != toastAttention {
t.Fatalf("snapshot kinds wrong: %#v", snap)
}
// IDs strictly increase.
if !(snap[0].id < snap[1].id && snap[1].id < snap[2].id) {
t.Fatalf("ids not increasing: %#v", snap)
}
}
func TestToastStackCapDropsOldest(t *testing.T) {
var s toastStack
for i := 0; i < toastStackCap+3; i++ {
s.push(toastInfo, "msg")
}
snap := s.snapshot()
if len(snap) != toastStackCap {
t.Fatalf("len = %d, want %d", len(snap), toastStackCap)
}
// The earliest IDs should have been dropped, leaving the highest
// toastStackCap IDs.
for i := 1; i < len(snap); i++ {
if snap[i].id <= snap[i-1].id {
t.Fatalf("ordering broken after cap: %#v", snap)
}
}
// First retained id should be 4 (1,2,3 dropped; cap=5 leaves 4..8).
want := uint64(toastStackCap + 3 - toastStackCap + 1)
if snap[0].id != want {
t.Fatalf("first retained id = %d, want %d", snap[0].id, want)
}
}
func TestToastStackDismissTop(t *testing.T) {
var s toastStack
if s.dismissTop() {
t.Fatalf("dismissTop on empty stack returned true")
}
s.push(toastInfo, "a")
s.push(toastError, "b")
if !s.dismissTop() {
t.Fatalf("dismissTop returned false with items present")
}
snap := s.snapshot()
if len(snap) != 1 || snap[0].text != "a" {
t.Fatalf("after dismissTop: %#v", snap)
}
if !s.dismissTop() {
t.Fatalf("dismissTop on last item returned false")
}
if s.length() != 0 {
t.Fatalf("length after final dismiss = %d, want 0", s.length())
}
}
func TestToastStackClear(t *testing.T) {
var s toastStack
if s.clear() {
t.Fatalf("clear on empty returned true")
}
s.push(toastInfo, "a")
s.push(toastError, "b")
s.push(toastAttention, "c")
if !s.clear() {
t.Fatalf("clear returned false with items present")
}
if s.length() != 0 {
t.Fatalf("length after clear = %d, want 0", s.length())
}
if snap := s.snapshot(); snap != nil {
t.Fatalf("snapshot after clear = %#v, want nil", snap)
}
}
func TestToastStackSnapshotIsCopy(t *testing.T) {
var s toastStack
s.push(toastInfo, "a")
snap := s.snapshot()
snap[0].text = "mutated"
again := s.snapshot()
if again[0].text != "a" {
t.Fatalf("snapshot is not an independent copy: %#v", again)
}
}
func TestWrapToastBodyFixedHeight(t *testing.T) {
got := wrapToastBody("short", 20)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
if got[0] != "short" {
t.Fatalf("line 0 = %q, want \"short\"", got[0])
}
if got[1] != "" || got[2] != "" {
t.Fatalf("trailing pads not empty: %#v", got)
}
}
func TestWrapToastBodyWrapsOnWordBoundary(t *testing.T) {
got := wrapToastBody("the quick brown fox jumps over", 10)
// Expect greedy fill: "the quick" (9), "brown fox" (9), "jumps over" (10).
want := []string{"the quick", "brown fox", "jumps over"}
for i, w := range want {
if got[i] != w {
t.Fatalf("line %d = %q, want %q (full=%#v)", i, got[i], w, got)
}
}
}
func TestWrapToastBodyEllipsizesOverflow(t *testing.T) {
got := wrapToastBody("alpha beta gamma delta epsilon zeta eta theta", 6)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
last := got[toastContentRows-1]
if !strings.HasSuffix(last, "…") {
t.Fatalf("overflow should ellipsize last line, got %q (full=%#v)", last, got)
}
if visibleLen(last) > 6 {
t.Fatalf("last line %q exceeds width 6", last)
}
}
func TestWrapToastBodyBreaksOverlongWord(t *testing.T) {
got := wrapToastBody("supercalifragilistic", 6)
if got[0] != "superc" {
t.Fatalf("line 0 = %q, want \"superc\"", got[0])
}
if got[1] != "alifra" {
t.Fatalf("line 1 = %q, want \"alifra\"", got[1])
}
// Third line should hold the rest (possibly ellipsized).
if got[2] == "" {
t.Fatalf("line 2 unexpectedly empty: %#v", got)
}
}
func TestWrapToastBodyEmptyInput(t *testing.T) {
got := wrapToastBody("", 20)
for i, l := range got {
if l != "" {
t.Fatalf("line %d = %q, want \"\"", i, l)
}
}
}

63
internal/app/token.go Normal file
View File

@@ -0,0 +1,63 @@
package app
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
)
func ClientTokenPath() (string, error) {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "patterm", "clients", "token"), nil
}
func LoadClientToken() (string, error) {
path, err := ClientTokenPath()
if err != nil {
return "", err
}
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
func LoadOrCreateClientToken() (string, error) {
if token, err := LoadClientToken(); err == nil && token != "" {
return token, nil
}
token, err := generateClientToken()
if err != nil {
return "", err
}
path, err := ClientTokenPath()
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil {
return "", err
}
return token, nil
}
func generateClientToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("token: random: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}

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

@@ -16,7 +16,7 @@ func bytesRepeat(b byte, n int) []byte {
func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[3;1H" {
if got != "\x1b[4;1H" {
t.Fatalf("CUP home: got %q", got)
}
}
@@ -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)
}
}
@@ -38,7 +66,7 @@ func TestViewportRendererSwallowsOriginModeToggles(t *testing.T) {
if !strings.Contains(got, "a") || !strings.Contains(got, "b") || !strings.Contains(got, "c") {
t.Fatalf("origin-mode toggles should not drop surrounding text: got %q", got)
}
if strings.Count(got, "\x1b[3;1H") != 2 {
if strings.Count(got, "\x1b[4;1H") != 2 {
t.Fatalf("origin-mode set/reset should home inside the viewport twice: got %q", got)
}
}
@@ -60,23 +88,23 @@ func TestViewportRendererOriginModeCUPUsesScrollTop(t *testing.T) {
if strings.Contains(got, "\x1b[?6h") {
t.Fatalf("origin-mode set leaked to host: %q", got)
}
if !strings.Contains(got, "\x1b[7;1H") {
t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 7: got %q", got)
if !strings.Contains(got, "\x1b[8;1H") {
t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 8: got %q", got)
}
}
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
// hostRows=7 leaves four viewport rows after the 2-row tab bar and
// hostRows=7 leaves three viewport rows after the 3-row tab bar and
// 1-row status reservation.
vr := newViewportRenderer(newTerminalLayout(20, 7))
got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got)
}
if strings.Count(got, "\x1b[20X") != 4 {
if strings.Count(got, "\x1b[20X") != 3 {
t.Fatalf("clear rows: got %q", got)
}
if !strings.Contains(got, "\x1b[3;1H") || !strings.Contains(got, "\x1b[6;1H") {
if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") {
t.Fatalf("clear did not target viewport rows: %q", got)
}
}
@@ -112,13 +140,12 @@ func TestViewportRendererClearToEndIsViewportOnly(t *testing.T) {
t.Fatalf("host clear-to-end leaked through: %q", got)
}
// childCols == 19 (40 cols - 28 sidebar - 1 gap - 0-index fudge).
// Each of the 4 viewport rows should get a 19-cell erase.
// childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved).
// 4 viewport rows, but the cursor row uses ECH at cursor (col 1),
// so we expect 4 erases of 11 cells each.
// 3 viewport rows, but the cursor row uses ECH at cursor (col 1),
// so we expect 3 erases of 11 cells each.
count := strings.Count(got, "\x1b[11X")
if count != 4 {
t.Fatalf("expected 4 ECH-11 sequences, got %d in %q", count, got)
if count != 3 {
t.Fatalf("expected 3 ECH-11 sequences, got %d in %q", count, got)
}
}
@@ -154,7 +181,7 @@ func TestViewportRendererClampsCUPColumn(t *testing.T) {
// column so the host cursor never lands in the sidebar.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95H")))
if !strings.Contains(got, "\x1b[7;91H") {
if !strings.Contains(got, "\x1b[8;91H") {
t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got)
}
}
@@ -249,7 +276,7 @@ func TestViewportRendererFlagsScrollVerbs(t *testing.T) {
func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[37;1H\n"))
_ = vr.Render([]byte("\x1b[36;1H\n"))
if !vr.TookScrollAction() {
t.Fatalf("LF at viewport bottom should flag scroll")
}
@@ -257,7 +284,7 @@ func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T)
func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[36;1H\n"))
_ = vr.Render([]byte("\x1b[35;1H\n"))
if vr.TookScrollAction() {
t.Fatalf("LF before viewport bottom should not flag scroll")
}
@@ -284,7 +311,7 @@ func TestViewportRendererClampsCUUAtViewportTop(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to viewport row 1 then CUU by 50.
got := string(vr.Render([]byte("\x1b[1;1H\x1b[50ACLOBBER")))
if !strings.Contains(got, "\x1b[3;1H") {
if !strings.Contains(got, "\x1b[4;1H") {
t.Fatalf("expected CUP shifted to mainTop: got %q", got)
}
// The CUU should have been swallowed (n clamped to 0 from row 1).
@@ -311,10 +338,10 @@ func TestViewportRendererClampsCUUPartial(t *testing.T) {
}
func TestViewportRendererClampsCUDAtViewportBottom(t *testing.T) {
// childRows=37 for layout(120, 40). Park cursor at row 37, ask for
// childRows=36 for layout(120, 40). Park cursor at row 36, ask for
// 10 down → safe step is 0.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[37;1H\x1b[10B")))
got := string(vr.Render([]byte("\x1b[36;1H\x1b[10B")))
if strings.Contains(got, "\x1b[10B") {
t.Fatalf("CUD past viewport bottom should be dropped: got %q", got)
}
@@ -335,10 +362,10 @@ func TestViewportRendererClampsCPLAndHomesColumn(t *testing.T) {
func TestViewportRendererClampsCNL(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to row 35 then CNL by 50 → safe step is 2 (childRows-35).
got := string(vr.Render([]byte("\x1b[35;10H\x1b[50E")))
// CUP to row 34 then CNL by 50 → safe step is 2 (childRows-34).
got := string(vr.Render([]byte("\x1b[34;10H\x1b[50E")))
if !strings.Contains(got, "\x1b[2E") {
t.Fatalf("CNL 50 from row 35 should clamp to 2: got %q", got)
t.Fatalf("CNL 50 from row 34 should clamp to 2: got %q", got)
}
}

View File

@@ -23,9 +23,9 @@ func TestRestartRestoresUserCommandProcess(t *testing.T) {
}
sc := &Scenario{
Name: "restart_persist",
Cols: 120,
Rows: 40,
Name: "restart_persist",
Cols: 120,
Rows: 40,
Trust: []string{"persist-target"},
Presets: ScenarioPresets{
Processes: []ScenarioPreset{{
@@ -143,7 +143,7 @@ func openSession(t *testing.T, env *testEnv, childEnv []string) *Session {
if err != nil {
t.Fatalf("vt emulator: %v", err)
}
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows)
p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
if err != nil {
_ = em.Close()
t.Fatalf("pty start: %v", err)

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,37 @@
{
"name": "error_flash_preserves_focused_pane",
"presets": {
"processes": [
{
"name": "steady",
"argv": ["sh", "-lc", "printf 'STEADY READY\\n'; sleep 5"]
}
]
},
"trust": ["steady"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "steady", "name": "steady"},
"save_as": "proc"
},
{ "type": "wait_text", "contains": "STEADY READY", "timeout_ms": 5000 },
{ "type": "send_chord", "chord": "ctrl-k" },
{ "type": "send_text", "text": "Open Settings" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "no active top-level agent to summarize", "timeout_ms": 5000 },
{ "type": "wait_text", "contains": "STEADY READY", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "STEADY READY" },
{ "type": "assert_not_contains", "contains": "Press Ctrl-K to spawn an agent or process" }
]
}

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

View File

@@ -16,7 +16,7 @@
{ "type": "send_chord", "chord": "ctrl-k" },
{ "type": "send_text", "text": "Rename process" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "Rename process", "timeout_ms": 3000 },
{ "type": "wait_text", "contains": "process: original", "timeout_ms": 3000 },
{ "type": "send_chord", "chord": "ctrl-u" },
{ "type": "send_text", "text": "renamed-pane" },
{ "type": "send_chord", "chord": "enter" },

View File

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

View File

@@ -5,7 +5,7 @@
"scripts": [
{
"name": "linefeed-scroll",
"body": "#!/bin/sh\n# Plain LF at the bottom of the child viewport scrolls the host's\n# DECSTBM region. Because that region spans every column, enough LFs\n# drag the sidebar border and section labels out of the visible region\n# unless patterm invalidates and repaints the sidebar cache.\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'warmup %02d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'LINEFEED READY\\n'\nIFS= read -r _\nprintf '\\033[1;37r'\nprintf '\\033[37;1H'\ni=0\nwhile [ $i -lt 45 ]; do\n printf 'scroll line %02d\\n' \"$i\"\n i=$((i + 1))\ndone\nprintf 'LINEFEED DONE\\n'\nsleep 5\n"
"body": "#!/bin/sh\n# Plain LF at the bottom of the child viewport scrolls the host's\n# DECSTBM region. Because that region spans every column, enough LFs\n# drag the sidebar border and section labels out of the visible region\n# unless patterm invalidates and repaints the sidebar cache.\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'warmup %02d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'LINEFEED READY\\n'\nIFS= read -r _\nprintf '\\033[1;36r'\nprintf '\\033[36;1H'\ni=0\nwhile [ $i -lt 45 ]; do\n printf 'scroll line %02d\\n' \"$i\"\n i=$((i + 1))\ndone\nprintf 'LINEFEED DONE\\n'\nsleep 5\n"
}
],
"steps": [
@@ -19,13 +19,13 @@
{ "type": "mark_raw", "save_as": "before_scroll" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "LINEFEED DONE", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{
"type": "assert_raw_since_regex",
"from": "before_scroll",
"regex": "Agent Tree",
"regex": "LINEFEED DONE",
"timeout_ms": 2000
},
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "Agent Tree" },
{ "type": "assert_contains", "contains": "Scratchpads" },

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

@@ -0,0 +1,32 @@
{
"name": "toast_dismiss",
"presets": {
"processes": [
{
"name": "steady",
"argv": ["sh", "-lc", "printf 'STEADY READY\\n'; sleep 30"]
}
]
},
"trust": ["steady"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "steady", "name": "steady"},
"save_as": "proc"
},
{ "type": "wait_text", "contains": "STEADY READY", "timeout_ms": 5000 },
{
"type": "mcp_call",
"method": "request_human_attention",
"params": {"process_id": "{{proc.process_id}}", "reason": "needs eyes on the deploy"}
},
{ "type": "wait_text", "contains": "needs eyes on the deploy", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "STEADY READY" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "STEADY READY" },
{ "type": "assert_not_contains", "contains": "needs eyes on the deploy" }
]
}

View File

@@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) {
if err != nil {
return nil, err
}
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows)
p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
if err != nil {
_ = em.Close()
return nil, err

View File

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

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

@@ -0,0 +1,190 @@
package mcp
import (
"bufio"
"encoding/json"
"fmt"
"net"
"sync"
"syscall"
"testing"
"time"
"github.com/hjbdev/patterm/internal/scratchpad"
)
func TestHandleConnDispatchesRequestsConcurrently(t *testing.T) {
serverConn, clientConn := net.Pipe()
t.Cleanup(func() { _ = clientConn.Close() })
host := &blockingToolHost{
waitEntered: make(chan struct{}),
waitRelease: make(chan struct{}),
}
s := &Server{}
s.SetHost(host)
done := make(chan struct{})
go func() {
s.handleConn(serverConn)
close(done)
}()
reader := bufio.NewReader(clientConn)
writeLine(t, clientConn, `{"patterm_identity":"ident"}`)
writeLine(t, clientConn, `{"jsonrpc":"2.0","id":1,"method":"wait_for_pattern","params":{"process_id":"p_slow","pattern":"never","timeout_seconds":300}}`)
select {
case <-host.waitEntered:
case <-time.After(time.Second):
t.Fatal("wait_for_pattern did not enter fake host")
}
writeLine(t, clientConn, `{"jsonrpc":"2.0","id":2,"method":"get_process_status","params":{"process_id":"p_fast"}}`)
fast := readJSONRPCResponse(t, clientConn, reader, time.Second)
if got := string(fast.ID); got != "2" {
t.Fatalf("first response id = %s, want 2; response=%s", got, fast.Raw)
}
if fast.Error != nil {
t.Fatalf("fast response returned error: %+v", fast.Error)
}
_ = clientConn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
if line, err := reader.ReadBytes('\n'); err == nil {
t.Fatalf("slow response arrived before release: %s", line)
}
close(host.waitRelease)
slow := readJSONRPCResponse(t, clientConn, reader, time.Second)
if got := string(slow.ID); got != "1" {
t.Fatalf("second response id = %s, want 1; response=%s", got, slow.Raw)
}
if slow.Error != nil {
t.Fatalf("slow response returned error: %+v", slow.Error)
}
_ = clientConn.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("handleConn did not exit after client close")
}
}
type jsonRPCResponse struct {
Raw string
ID json.RawMessage `json:"id"`
Result map[string]any `json:"result"`
Error *jsonRPCErrorShape `json:"error"`
}
type jsonRPCErrorShape struct {
Code int `json:"code"`
Message string `json:"message"`
}
func writeLine(t *testing.T, conn net.Conn, line string) {
t.Helper()
_ = conn.SetWriteDeadline(time.Now().Add(time.Second))
if _, err := fmt.Fprintln(conn, line); err != nil {
t.Fatalf("write %s: %v", line, err)
}
}
func readJSONRPCResponse(t *testing.T, conn net.Conn, reader *bufio.Reader, timeout time.Duration) jsonRPCResponse {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(timeout))
line, err := reader.ReadBytes('\n')
if err != nil {
t.Fatalf("read response: %v", err)
}
var resp jsonRPCResponse
resp.Raw = string(line)
if err := json.Unmarshal(line, &resp); err != nil {
t.Fatalf("parse response %s: %v", line, err)
}
return resp
}
type blockingToolHost struct {
waitEntered chan struct{}
waitRelease chan struct{}
waitOnce sync.Once
}
func (h *blockingToolHost) ResolveCallerIdentity(identity string) string { return "caller-" + identity }
func (h *blockingToolHost) CallerRole(string) CallerRole { return RoleOrchestrator }
func (h *blockingToolHost) SpawnAgent(string, SpawnAgentArgs) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) SpawnProcess(string, SpawnProcessArgs) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) StartProcess(string, string) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) RestartProcess(string, string, syscall.Signal) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) StopProcess(string, string, syscall.Signal) (ProcessInfo, error) {
return ProcessInfo{}, nil
}
func (h *blockingToolHost) CloseProcess(string, string) error { return nil }
func (h *blockingToolHost) RenameProcess(string, string, string) error { return nil }
func (h *blockingToolHost) SelectProcess(string, string) error { return nil }
func (h *blockingToolHost) ListProcesses(string, string) []ProcessInfo { return nil }
func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) {
return ProcessStatus{ProcessInfo: ProcessInfo{ID: "p_fast", Status: "running"}}, nil
}
func (h *blockingToolHost) GetProjectStatus(string) (ProjectStatus, error) {
return ProjectStatus{}, nil
}
func (h *blockingToolHost) GetProcessOutput(string, string, string, int64) (ProcessOutput, error) {
return ProcessOutput{}, nil
}
func (h *blockingToolHost) GetProcessRawOutput(string, string, int64) (RawOutput, error) {
return RawOutput{}, nil
}
func (h *blockingToolHost) SearchOutput(string, string, string, string, int) (SearchResult, error) {
return SearchResult{}, nil
}
func (h *blockingToolHost) WaitForPattern(string, string, string, float64, string) (bool, string, error) {
h.waitOnce.Do(func() { close(h.waitEntered) })
<-h.waitRelease
return true, "matched", nil
}
func (h *blockingToolHost) GetProcessPorts(string, string) ([]PortSighting, error) {
return nil, nil
}
func (h *blockingToolHost) SendInput(string, SendInputArgs) (SendInputResult, error) {
return SendInputResult{}, nil
}
func (h *blockingToolHost) SendMessage(string, string, string) error { return nil }
func (h *blockingToolHost) RequestHumanAttention(string, string, string) error { return nil }
func (h *blockingToolHost) TimerWait(string, float64, string) (string, error) {
return "", nil
}
func (h *blockingToolHost) TimerSet(string, TimerSetArgs) (TimerHandle, error) {
return TimerHandle{}, nil
}
func (h *blockingToolHost) TimerFireWhenIdleAny(string, TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error) {
return TimerFireWhenIdleResponse{}, nil
}
func (h *blockingToolHost) TimerFireWhenIdleAll(string, TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error) {
return TimerFireWhenIdleResponse{}, nil
}
func (h *blockingToolHost) TimerCancel(string, string) error { return nil }
func (h *blockingToolHost) TimerPause(string, string) error { return nil }
func (h *blockingToolHost) TimerResume(string, string) error { return nil }
func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) {
return nil, nil
}
func (h *blockingToolHost) ScratchpadList(string) ([]scratchpad.Entry, error) { return nil, nil }
func (h *blockingToolHost) ScratchpadRead(string, string) (string, string, error) {
return "", "", nil
}
func (h *blockingToolHost) ScratchpadWrite(string, string, string, string) (string, error) {
return "", nil
}
func (h *blockingToolHost) ScratchpadAppend(string, string, string) error { return nil }
func (h *blockingToolHost) ScratchpadDelete(string, string) error { return nil }
func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} }
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }

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. When you `send_message` a sub-agent, its reply comes back into YOUR pane as `[sub-agent:<name>] …`, not into the sub-agent's output — to wait for it, use `timer_fire_when_idle_any([sub_agent])` and then read your own pane; do NOT `wait_for_pattern` on the sub-agent, that will deadlock until timeout."
// 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."),
@@ -193,7 +219,7 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "wait_for_pattern",
Description: "Block until pattern appears in process output or timeout elapses.",
Description: "Block until pattern appears in the TARGET process's own output, or timeout elapses. Use this for waiting on text the target itself will emit (a shell prompt, a build's \"tests passed\" line, etc.). Anti-pattern: do NOT use this to wait for a sub-agent's reply to send_message — replies are routed into the CALLER's pane tagged `[sub-agent:<name>]`, not into the sub-agent's output, so this call will spin to timeout. For sub-agent coordination use `timer_fire_when_idle_any` and then read your own pane.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."),
@@ -223,7 +249,7 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "send_message",
Description: "Deliver a text message to another process as orchestrator-owned input.",
Description: "Deliver a text message to another process as orchestrator-owned input. Fire-and-forget: returns immediately, without waiting for the recipient to read or act. If the recipient replies via send_message, that reply arrives in YOUR pane tagged `[sub-agent:<name>]` (child→parent) or `[orchestrator]` (parent→child) — NOT in the recipient's output. To wait for a sub-agent's reply, schedule `timer_fire_when_idle_any([sub_agent_id], body=…)` and then read your own pane when the timer fires. Do not `wait_for_pattern` on the recipient for a reply; it will deadlock.",
InputSchema: objectSchema(map[string]any{
"target_process_id": stringProp("Recipient process id."),
"message": stringProp("Message body."),
@@ -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: "Canonical way to wait for a sub-agent to finish working: send_message the sub-agent, then schedule this with watched=[sub_agent_id]; when it fires, the reply is already sitting in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
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: "Canonical way to wait for several sub-agents to finish working in parallel: send_message each one, then schedule this with watched=[…ids]; when it fires, each reply is in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
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.",
@@ -274,6 +358,13 @@ func toolCatalog() []toolDescriptor {
"content": stringProp("Text to append."),
}, []string{"name", "content"}),
},
{
Name: "scratchpad_delete",
Description: "Delete a scratchpad entry.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
}, []string{"name"}),
},
{
Name: "whoami",
Description: "Return the caller's identity, role, parent, project metadata, and available tools.",
@@ -311,7 +402,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,12 +88,20 @@ 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)
ScratchpadRead(name string) (content string, revision string, err error)
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(name, content string) error
ScratchpadList(callerID string) ([]scratchpad.Entry, error)
ScratchpadRead(callerID, name string) (content string, revision string, err error)
ScratchpadWrite(callerID, name, content, expectedRevision string) (revision string, err error)
ScratchpadAppend(callerID, name, content string) error
ScratchpadDelete(callerID, name string) error
// Meta.
WhoAmI(callerID string) WhoAmI
@@ -111,6 +119,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 +196,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,8 +647,84 @@ 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()
entries, err := h.ScratchpadList(callerID)
if err != nil {
return nil, codeInternal, err.Error(), nil
}
@@ -589,7 +737,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
content, rev, err := h.ScratchpadRead(p.Name)
content, rev, err := h.ScratchpadRead(callerID, p.Name)
if err != nil {
return nil, codeInternal, err.Error(), nil
}
@@ -604,7 +752,7 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
rev, err := h.ScratchpadWrite(p.Name, p.Content, p.ExpectedRevision)
rev, err := h.ScratchpadWrite(callerID, p.Name, p.Content, p.ExpectedRevision)
if err != nil {
// Optimistic-concurrency miss returns ok:false + current_revision
// rather than a JSON-RPC error so callers can re-read + merge.
@@ -624,7 +772,19 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ScratchpadAppend(p.Name, p.Content); err != nil {
if err := h.ScratchpadAppend(callerID, p.Name, p.Content); err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"ok": true}, 0, "", nil
case "scratchpad_delete":
var p struct {
Name string `json:"name"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.ScratchpadDelete(callerID, p.Name); err != nil {
return nil, codeInternal, err.Error(), nil
}
return map[string]any{"ok": true}, 0, "", nil

View File

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

View File

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

176
internal/protocol/frame.go Normal file
View File

@@ -0,0 +1,176 @@
// Package protocol defines the daemon/client control frames shared by
// transports. It intentionally contains data shapes only; app behavior stays
// in internal/app until the headless daemon split is complete.
package protocol
import (
"encoding/json"
"fmt"
"time"
)
// FrameType identifies one protocol message kind.
type FrameType string
const (
FrameHello FrameType = "hello"
FrameAuthChallenge FrameType = "auth_challenge"
FrameAuthOK FrameType = "auth_ok"
FrameAttach FrameType = "attach"
FrameDetach FrameType = "detach"
FrameProjectList FrameType = "project_list"
FrameChrome FrameType = "chrome"
FramePaneSnapshot FrameType = "pane_snapshot"
FramePaneChunk FrameType = "pane_chunk"
FrameLifecycle FrameType = "lifecycle"
FrameAttention FrameType = "attention"
FrameTrustPrompt FrameType = "trust_prompt"
FrameInput FrameType = "input"
FrameFocus FrameType = "focus"
FrameSwitchProject FrameType = "switch_project"
FrameOpenProject FrameType = "open_project"
FramePaletteCommand FrameType = "palette_command"
FrameTrustResponse FrameType = "trust_response"
FrameResize FrameType = "resize"
FrameList FrameType = "list"
FrameStop FrameType = "stop"
FrameError FrameType = "error"
)
// Frame is the transport envelope. Payload is deliberately raw JSON so
// network transports can frame without knowing every message type; loopback
// transports may pass the same bytes without JSON re-encoding.
type Frame struct {
Type FrameType `json:"type"`
RequestID string `json:"request_id,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewFrame marshals payload into a protocol frame.
func NewFrame[T any](typ FrameType, payload T) (Frame, error) {
b, err := json.Marshal(payload)
if err != nil {
return Frame{}, fmt.Errorf("protocol: marshal %s: %w", typ, err)
}
return Frame{Type: typ, Payload: b}, nil
}
// Decode unmarshals f.Payload into v.
func Decode[T any](f Frame) (T, error) {
var v T
if len(f.Payload) == 0 {
return v, nil
}
if err := json.Unmarshal(f.Payload, &v); err != nil {
return v, fmt.Errorf("protocol: decode %s: %w", f.Type, err)
}
return v, nil
}
type Hello struct {
Version int `json:"version"`
DaemonID string `json:"daemon_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
}
type Attach struct {
Token string `json:"token,omitempty"`
ProjectKey string `json:"project_key,omitempty"`
ProjectPath string `json:"project_path,omitempty"`
TermSize Size `json:"term_size"`
}
type Detach struct {
ClientID string `json:"client_id,omitempty"`
}
type Size struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
type Project struct {
Key string `json:"key"`
Path string `json:"path"`
Name string `json:"name"`
LastActive time.Time `json:"last_active,omitempty"`
TabCount int `json:"tab_count"`
}
type ProjectList struct {
Projects []Project `json:"projects"`
}
type Chrome struct {
ProjectKey string `json:"project_key"`
Model json.RawMessage `json:"model"`
}
type PaneSnapshot struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
Size Size `json:"size,omitempty"`
DisplayOwner bool `json:"display_owner,omitempty"`
}
type PaneChunk struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
Size Size `json:"size,omitempty"`
DisplayOwner bool `json:"display_owner,omitempty"`
}
type LifecycleKind string
const (
LifecycleSpawned LifecycleKind = "spawned"
LifecycleExited LifecycleKind = "exited"
LifecycleClosed LifecycleKind = "closed"
LifecycleStateChanged LifecycleKind = "state_changed"
)
type Lifecycle struct {
Kind LifecycleKind `json:"kind"`
ProjectKey string `json:"project_key,omitempty"`
ChildID string `json:"child_id,omitempty"`
Child json.RawMessage `json:"child,omitempty"`
State string `json:"state,omitempty"`
}
type Input struct {
PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"`
}
type Focus struct {
PaneID string `json:"pane_id,omitempty"`
Pad string `json:"pad,omitempty"`
}
type SwitchProject struct {
Key string `json:"key"`
}
type OpenProject struct {
Path string `json:"path"`
}
type PaletteCommand struct {
Kind string `json:"kind"`
Data json.RawMessage `json:"data,omitempty"`
}
type TrustResponse struct {
ProcessID string `json:"process_id"`
Preset string `json:"preset"`
Allow bool `json:"allow"`
}
type Resize struct {
Size Size `json:"size"`
}
type Error struct {
Message string `json:"message"`
}

View File

@@ -0,0 +1,67 @@
package protocol
import (
"sync"
)
const defaultLoopbackBuffer = 64
// NewLoopbackPair returns connected in-process transports. Frames cross the
// same Send/Recv boundary as network transports, but payload bytes are passed
// directly without JSON re-encoding.
func NewLoopbackPair() (client Transport, daemon Transport) {
c2d := make(chan Frame, defaultLoopbackBuffer)
d2c := make(chan Frame, defaultLoopbackBuffer)
return &loopbackTransport{send: c2d, recv: d2c}, &loopbackTransport{send: d2c, recv: c2d}
}
type loopbackTransport struct {
send chan<- Frame
recv <-chan Frame
once sync.Once
done chan struct{}
}
func (t *loopbackTransport) init() {
if t.done == nil {
t.done = make(chan struct{})
}
}
func (t *loopbackTransport) Send(f Frame) error {
t.init()
select {
case <-t.done:
return ErrTransportClosed
case t.send <- cloneFrame(f):
return nil
}
}
func (t *loopbackTransport) Recv() (Frame, error) {
t.init()
select {
case <-t.done:
return Frame{}, ErrTransportClosed
case f, ok := <-t.recv:
if !ok {
return Frame{}, ErrTransportClosed
}
return f, nil
}
}
func (t *loopbackTransport) Close() error {
t.init()
t.once.Do(func() {
close(t.done)
})
return nil
}
func cloneFrame(f Frame) Frame {
if len(f.Payload) > 0 {
f.Payload = append([]byte(nil), f.Payload...)
}
return f
}

View File

@@ -0,0 +1,51 @@
package protocol
import "testing"
func TestLoopbackUsesFramePayload(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
sent, err := NewFrame(FrameInput, Input{PaneID: "p_123456", Bytes: []byte("hello")})
if err != nil {
t.Fatalf("NewFrame: %v", err)
}
if err := client.Send(sent); err != nil {
t.Fatalf("Send: %v", err)
}
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Type != FrameInput {
t.Fatalf("type = %q, want %q", got.Type, FrameInput)
}
payload, err := Decode[Input](got)
if err != nil {
t.Fatalf("Decode: %v", err)
}
if payload.PaneID != "p_123456" || string(payload.Bytes) != "hello" {
t.Fatalf("payload = %#v", payload)
}
}
func TestLoopbackCopiesPayloadOnSend(t *testing.T) {
client, daemon := NewLoopbackPair()
defer client.Close()
defer daemon.Close()
f := Frame{Type: FramePaneChunk, Payload: []byte(`{"pane_id":"p","bytes":"aGVsbG8="}`)}
if err := client.Send(f); err != nil {
t.Fatalf("Send: %v", err)
}
f.Payload[0] = 'x'
got, err := daemon.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
if got.Payload[0] != '{' {
t.Fatalf("payload was retained instead of copied: %q", string(got.Payload))
}
}

View File

@@ -0,0 +1,80 @@
package protocol
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"sync"
)
var ErrTransportClosed = errors.New("protocol: transport closed")
// Transport carries framed daemon/client protocol messages.
type Transport interface {
Send(Frame) error
Recv() (Frame, error)
Close() error
}
// ConnTransport is a JSON-lines implementation over a stream connection. Send
// is guarded by a mutex so the daemon can push frames from its subscriber pump
// and its command handlers concurrently; Close may be called from any goroutine
// (e.g. on context cancellation) to unblock a pending Recv.
type ConnTransport struct {
conn net.Conn
r *bufio.Reader
wmu sync.Mutex
w *bufio.Writer
}
func NewConnTransport(conn net.Conn) *ConnTransport {
return &ConnTransport{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
}
func (t *ConnTransport) Send(f Frame) error {
if t == nil || t.conn == nil {
return ErrTransportClosed
}
b, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("protocol: encode frame: %w", err)
}
t.wmu.Lock()
defer t.wmu.Unlock()
if _, err := t.w.Write(append(b, '\n')); err != nil {
return err
}
return t.w.Flush()
}
func (t *ConnTransport) Recv() (Frame, error) {
if t == nil || t.conn == nil {
return Frame{}, ErrTransportClosed
}
line, err := t.r.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return Frame{}, ErrTransportClosed
}
return Frame{}, err
}
var f Frame
if err := json.Unmarshal(line, &f); err != nil {
return Frame{}, fmt.Errorf("protocol: decode frame: %w", err)
}
return f, nil
}
func (t *ConnTransport) Close() error {
if t == nil || t.conn == nil {
return nil
}
return t.conn.Close()
}

View File

@@ -6,12 +6,22 @@ import (
"io"
"os"
"os/exec"
"sync"
"syscall"
cpty "github.com/creack/pty"
)
// PTY holds a child process attached to a pseudo-terminal master fd.
//
// mu guards the master field only. Read/Write/Resize capture the *os.File
// under the lock and then do the (potentially blocking) I/O without holding
// it, so Close can swap master to nil and close the fd concurrently — closing
// the captured *os.File unblocks an in-flight Read. This avoids a data race
// between pumpChild's Read and Session.Shutdown's Close, which the daemon now
// hits routinely (daemon stop, not just process exit).
type PTY struct {
mu sync.Mutex
master *os.File
cmd *exec.Cmd
}
@@ -19,11 +29,13 @@ type PTY struct {
// Start spawns argv with stdin/stdout/stderr attached to a new PTY sized
// (cols, rows). The returned PTY exposes the master fd for the parent to
// read from and write to.
func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) {
func Start(argv []string, env []string, workDir string, cols, rows uint16) (*PTY, error) {
if len(argv) == 0 {
return nil, fmt.Errorf("pty: empty argv")
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Dir = workDir
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
if env != nil {
cmd.Env = ensureTerm(env)
} else {
@@ -42,24 +54,33 @@ func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) {
}
func (p *PTY) Read(b []byte) (int, error) {
if p.master == nil {
p.mu.Lock()
m := p.master
p.mu.Unlock()
if m == nil {
return 0, io.ErrClosedPipe
}
return p.master.Read(b)
return m.Read(b)
}
func (p *PTY) Write(b []byte) (int, error) {
if p.master == nil {
p.mu.Lock()
m := p.master
p.mu.Unlock()
if m == nil {
return 0, io.ErrClosedPipe
}
return p.master.Write(b)
return m.Write(b)
}
func (p *PTY) Resize(cols, rows uint16) error {
if p.master == nil {
p.mu.Lock()
m := p.master
p.mu.Unlock()
if m == nil {
return io.ErrClosedPipe
}
return cpty.Setsize(p.master, &cpty.Winsize{Cols: cols, Rows: rows})
return cpty.Setsize(m, &cpty.Winsize{Cols: cols, Rows: rows})
}
// Wait blocks until the child exits and returns its exit error if any.
@@ -80,14 +101,21 @@ func (p *PTY) Pid() int {
// Close terminates the child (best effort) and releases the master fd.
func (p *PTY) Close() error {
p.mu.Lock()
m := p.master
p.master = nil
p.mu.Unlock()
var firstErr error
if p.master != nil {
if err := p.master.Close(); err != nil && firstErr == nil {
if m != nil {
if err := m.Close(); err != nil {
firstErr = err
}
p.master = nil
}
if p.cmd != nil && p.cmd.Process != nil {
pid := p.cmd.Process.Pid
if pid > 0 {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
_ = p.cmd.Process.Kill()
}
return firstErr

84
internal/pty/pty_test.go Normal file
View File

@@ -0,0 +1,84 @@
package pty
import (
"bytes"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestStartUsesWorkDir(t *testing.T) {
dir := t.TempDir()
p, err := Start([]string{"sh", "-c", "pwd"}, nil, dir, 80, 24)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
var out bytes.Buffer
buf := make([]byte, 256)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := p.Read(buf)
if n > 0 {
out.Write(buf[:n])
if strings.Contains(out.String(), dir) {
break
}
}
if err != nil {
break
}
}
_ = p.Wait()
if got := strings.TrimSpace(out.String()); got != dir {
t.Fatalf("pwd output = %q, want %q", got, dir)
}
}
func TestCloseKillsProcessGroup(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "sleep.pid")
env := append(os.Environ(), "PIDFILE="+pidFile)
p, err := Start([]string{"sh", "-c", "sleep 30 & echo $! > \"$PIDFILE\"; wait"}, env, "", 80, 24)
if err != nil {
t.Fatalf("Start: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
var childPID int
for time.Now().Before(deadline) {
b, err := os.ReadFile(pidFile)
if err == nil {
childPID, _ = strconv.Atoi(strings.TrimSpace(string(b)))
if childPID > 0 {
break
}
}
time.Sleep(20 * time.Millisecond)
}
if childPID <= 0 {
_ = p.Close()
t.Fatalf("background child pid was not written")
}
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
_ = p.Wait()
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
err := syscall.Kill(childPID, 0)
if errors.Is(err, syscall.ESRCH) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("background child pid %d still exists after PTY.Close", childPID)
}

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 }