Skip to content

feat(rust): terminal-CLI activity (TERM-15), turn completion (TERM-16) + terminal.idle#525

Merged
danshapiro merged 9 commits into
mainfrom
feat/rust-terminal-activity-idle
Jul 24, 2026
Merged

feat(rust): terminal-CLI activity (TERM-15), turn completion (TERM-16) + terminal.idle#525
danshapiro merged 9 commits into
mainfrom
feat/rust-terminal-activity-idle

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

TERM-15 + TERM-16 on the Rust server: terminal-CLI activity (blue/busy), turn completion, and the NEW terminal.idle truly-idle edge

Confirmed diagnosis (the motivating bug)

Amplifier/codex terminal panes never turn blue on the Rust server because TERM-15 was entirely unimplemented there:

  • The Rust WS layer matched every *.activity.list request into a comment-documented ignore fallback (crates/freshell-ws/src/terminal.rs: "Everything else (opencode fresh-agent, activity lists, other ui.) is out of scope for this path; ignore"*), so the frozen client's on-connect list requests (src/App.tsx:676-711) got no response.
  • Nothing anywhere in crates/ emitted *.activity.updated or terminal.turn.complete — there were no trackers at all.
  • amplifier.activity.list / .list.response / .updated didn't even exist in freshell-protocol (the frozen T0 inventory predates legacy's amplifier provider), so the client's amplifier list request wasn't even parseable.

The client's resolvePaneActivity path was intact and proven working (it renders blue the moment records exist) — the server side was simply absent.

What this PR adds

crates/freshell-activity (new crate, 62 unit tests) — pure, timer-free state machines ported from the frozen legacy parity reference:

  • signalshared/turn-complete-signal.ts port (BEL parser: OSC/DCS/CSI suppression, sandwiched-bell rejection, cross-chunk state).
  • ledgerturn-completion-ledger.ts port (monotonic per-terminal completionSeq, latest-snapshot list for reconnect dedupe).
  • claudeclaude-activity-tracker.ts port (submit→busy, Stop-hook BEL→exactly one completion, busy deadman self-heal with NO completion).
  • codexcodex-activity-tracker.ts PTY lane port (submit→pending [rendered blue], output liveness across long turns, BEL→idle + completion deduped per turn key, queued-submit re-arm).
  • amplifieramplifier-events-{reducer,tailer}.ts + amplifier-activity-tracker.ts ports (schema gate, prefilter, bounded partial-line buffer with oversized-line skip [100k+-token lines are normal], offset-incremental reads, file-reset degrade; PTY Enter is only PROVISIONALLY busy with force-read-then-revert grace; prompt:submit confirms; prompt:complete/session:end is the single boundary; deadman force-reads and STAYS busy — never fabricates a completion).
  • idle — the NEW truly-idle gate.

Registry activity tap (freshell-terminal): ActivityEvent Created/Input/Output/Exit observer — Input/Output fire only for CLI modes (shell terminals pay zero per-chunk cost); Exit fires on both kill and natural exit (no stale blue).

Activity hub (freshell-ws/src/activity.rs): routes tap events by mode into the trackers, answers all four *.activity.list requests from live state (reconnect seeding), broadcasts claude|codex|amplifier.activity.updated, terminal.turn.complete, and terminal.idle; owns one inotify watcher + offset tailer per associated amplifier terminal (attach at Start on locator association — the replayed prompt:submit confirms provisional busy; Eof on resume, resolved via a bounded one-time projects walk).

Protocol: amplifier activity family + terminal.idle added as an explicit EXTENSION surface (EXTENSION_{CLIENT,SERVER}_MESSAGE_TYPES + shape-pinning tests) so tests/inventory.rs keeps pinning the frozen T0 contract untouched. AgentProvider gains amplifier (legacy TerminalTurnCompleteSchema.provider parity).

NEW pinned contract — terminal.idle: { terminalId, at (server epoch ms), reason: 'grace' | 'queue-empty' }, emitted ONCE per busy→truly-idle transition: armed at the positive turn boundary, 2000ms grace window, cancelled by busy re-entry, extended by amplifier events-file appends (post-complete background naming events defer it). Subagent/tool completions inside a running turn never reach the gate — the trackers only report real turn boundaries.

Zero-polling (hard constraint)

  • No sweep intervals anywhere (deviation from legacy's 5s setInterval sweeps, by design): every tracker exposes next_deadline(); the hub arms at most ONE one-shot timer, re-armed only on events/expiry. Nothing busy ⇒ no timer, no wakes.
  • Amplifier lanes are inotify tailers reading only appended bytes; the watcher forwards only data-mutation events (access/atime filtered, so our own reads can't self-trigger).
  • Proven by idle_terminals_arm_no_timers_and_read_no_files (20 idle tracked terminals: zero timer wakes, zero file reads over an observed window, next_deadline() == None) and the inotify lane test (tail-read counter unchanged across a quiet window; increments only on a real write).

What's proven per CLI

CLI busy (blue) turn.complete terminal.idle queued-prompt detection
claude PW: raw busy upsert + blue tab chrome PW: exactly one, seq 1; seq 2 after mid-turn reload (dedupe basis) PW: exactly one, grace PTY submit-while-busy → next turn's busy cancels the window (PW: queued prompt suppresses idle — unit; suppression via re-busy — PW claude scenario asserts no early idle)
codex PW: raw pending upsert + blue chrome PW: exactly one PW: exactly one, grace same PTY heuristic (grace fallback)
amplifier PW: provisional busy + events-lane-confirmed busy (sessionId-bound upsert) PW: exactly one, provider amplifier, sessionId fake-amp-* PW: exactly one, grace events-file appends extend the window; prompt:submit cancels
opencode list response answered (correct idle shape — OpencodePhase only has busy) deferred (grace-only fallback accepted; parallel track researching)
gemini/kimi status-inert (unit-proven: no tracker, no frames) never never n/a

Production signal availability was verified: the Rust launch path already installs claude's Stop-hook bell (--settings) and codex's tui.notification_method=bel + tui.notifications=['agent-turn-complete'] (freshell-platform::cli_launch), so the PTY BEL lanes are live for real CLIs, not just fixtures.

Verification

  • cargo test --workspace: 1599 passed, 0 failed (incl. 62 freshell-activity, 6 hub, 3 registry-tap, protocol extension + inventory tests).
  • cargo fmt clean on all touched crates; cargo clippy introduces zero new warnings (remaining ones pre-exist on main in untouched files).
  • test/e2e-browser/specs/terminal-activity-rust.spec.ts (rust-chromium): green 2x — 3/3 in 39.1s, 3/3 in 37.8s. Raw frames asserted via a node-side capture socket; blue via the real tab chrome.
  • Coordinated FRESHELL_TEST_SUMMARY="track3 rust activity pre-PR" npm test: full-suite success exit=0 (default 3968 passed, server 4529 passed, electron 350 passed).

Deferred / documented deviations

  • OpenCode live tracking — legacy's lane is SSE off the shared opencode serve sidecar, which Rust terminal panes don't run; list contract answered, live updates deferred to the opencode research track.
  • Codex JSONL-reconcile lane (busy/unknown phases, resume-busy seeding from rollout files, app-server turn events) — unported; codex tracks from create with the PTY lane only (documented in crates/freshell-activity/src/codex.rs; legacy only tracked indexer-bound terminals, which would have reproduced the never-blue bug here).
  • Deadman force-read repeat at the deadman interval instead of legacy's 5s sweep cadence (zero-polling constraint; force-read is idempotent).
  • Green-attention/sound DOM edge not asserted in PW — this PR proves the server emission; the truly-idle client rework track owns client consumption of terminal.idle.
  • Checklist items TERM-15/TERM-16 updated with PARTIAL evidence (boxes deliberately NOT checked; each item's remaining clauses are listed there).

🤖 Generated with Amplifier

codex and others added 9 commits July 23, 2026 23:44
…urface

The frozen T0 inventory (27+52) predates legacy's amplifier activity
schemas and the NEW terminal.idle capability, so these are declared as
an explicit EXTENSION surface (EXTENSION_CLIENT_MESSAGE_TYPES /
EXTENSION_SERVER_MESSAGE_TYPES) with their own shape-pinning test,
leaving tests/inventory.rs and port/contract untouched.

terminal.idle pinned contract: { terminalId, at (server epoch ms),
reason: 'grace' | 'queue-empty' }, emitted once per busy->truly-idle
transition.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…rser, idle gate

Ports of the frozen legacy stack: shared/turn-complete-signal.ts,
turn-completion-ledger.ts, claude-activity-tracker.ts, the
codex-activity-tracker.ts PTY lane, and the amplifier events
reducer/tailer/tracker trio — plus the NEW terminal.idle grace gate.

Zero-polling: every tracker is a pure state machine exposing
next_deadline(); the hub arms one one-shot timer per provider and
nothing is armed while nothing is busy. Documented deviations: codex
tracks from create (the JSONL-reconcile lane is unported), deadman
force-read repeats at the deadman interval instead of the legacy 5s
sweep cadence.

62 unit tests: busy/turn/idle sequences, queued-submit re-arm,
OSC/DCS BEL suppression, deadman self-heal, amplifier grace
force-read-then-revert, tailer offset/partial/oversized/reset/schema
paths, idle-gate arm/extend/cancel/once semantics.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
ActivityEvent (Created/Input/Output/Exit) + set_activity_observer on
TerminalRegistry: Created/Exit fire for every mode (kill + natural
exit paths), Input/Output only for CLI modes so shell terminals pay
zero per-chunk tap cost. Observer runs on the caller / PTY reader
thread and must stay non-blocking (the hub forwards into a channel).

3 tests: full CLI-mode event flow over a real PTY, shell-mode tap
silence, natural-exit tap.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
ActivityHub consumes the registry tap, routes by mode into the
claude/codex/amplifier trackers (gemini/kimi stay status-inert),
broadcasts *.activity.updated / terminal.turn.complete / terminal.idle,
and answers all four *.activity.list requests from live tracker state
(reconnect seeding via per-terminal completionSeq). OpenCode list
responds with the correct idle shape (phase enum only has 'busy');
its live SSE lane is deferred.

Amplifier events lanes: one inotify watcher + offset tailer per
associated terminal (attach at Start on locator association — the
replayed prompt:submit confirms the provisional busy; Eof on resume).
Watcher filters to data-mutation events so our own reads can't
self-trigger; stats counters prove tail reads happen only on writes.

Zero-polling: the hub task sleeps on the event channel plus at most
ONE one-shot deadline (min of tracker next_deadline()s); idle state
arms nothing. Includes the 20-idle-terminals zero-wake test.

AgentProvider gains 'amplifier' (legacy TerminalTurnCompleteSchema
parity); freshAgent dispatch drops it explicitly.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Constructs the ActivityHub at boot, installs its registry observer,
and shares it into WsState. Resumed amplifier terminals resolve their
events.jsonl via one bounded projects walk at create time; fresh
sessions get their path from the locator association.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Three scenarios over real fake CLIs and RAW captured WS frames (a
node-side capture socket) plus the blue chrome the frozen client
derives from them:

- claude PTY lane: busy blue on submit, BEL -> idle + exactly one
  terminal.turn.complete (seq 1) + exactly one terminal.idle (grace);
  reload during a slow busy turn re-seeds blue from the activity-list
  response and the later completion carries seq 2.
- codex PTY lane: pending blue, BEL completes once, terminal.idle.
- amplifier events lane: provisional busy from the PTY Enter, the
  locator association attaches the inotify tailer, prompt:complete
  drives idle + turn.complete (provider amplifier) + terminal.idle.

New fixtures: fake-bel-cli.mjs (prompt-controlled turn duration, bare
BEL turn-complete) and fake-amplifier-activity-cli.mjs (schema-carrying
amplifier.log lifecycle records with live timestamps).

Green 2x: 3 passed (39.1s), 3 passed (37.8s).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ty engine

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Conflict resolutions (both sides' behavior preserved everywhere):

- crates/freshell-ws/src/lib.rs, terminal.rs, opencode_association.rs
  + 12 tests/*.rs WsState constructors: kept BOTH this branch's
  `activity: None` field and #523's new `session_existence` probe
  field (trivial adjacent-field conflicts). #523's brand-new
  tests/pane_reconcile.rs gained `activity: None` too.
- crates/freshell-terminal/src/registry.rs: kept BOTH the TERM-15/16
  activity tap (ActivityEvent/ActivityObserver, tap field + init, the
  Created-tap fire in create()) and #523's reconciliation additions
  (respawn liveness/cap consts + fields, keyed_create_inflight, the
  §5.4 duplicate-live-PTY backstop — ordered before the Created tap).
  create() gained #523's create_request_id param; this branch's two
  activity-observer tests updated to pass None for it.
- crates/freshell-server/src/main.rs: kept #523's session_index block
  first (ws_state.session_existence references it), then this branch's
  activity-hub construction + registry observer install.
- docs checklist + playwright.config.ts auto-merged (both sides' notes
  and MATRIX/rust-only spec entries retained).

Cross-track integration (#524 truly-idle client): the Rust
terminal.idle emission matches shared/ws-protocol.ts's new
TerminalIdleSchema byte-for-byte ({type, terminalId, at, reason:
'grace'|'queue-empty'}), so the rust leg of
test/e2e-browser/specs/truly-idle-alerting.spec.ts is now ENABLED
(test.fixme removed) — the full production pipeline (PTY submit ->
busy blue -> BEL -> idle + turn.complete -> 2s grace -> terminal.idle
-> one alert edge + tab shade) passes against the real Rust server.

Verification: cargo test --workspace 1656 passed / 0 failed; clippy
clean on touched files; rust-chromium PW (3 activity + truly-idle
rust leg) green 2x (4 passed 1.1m, 4 passed 42.5s); coordinated
npm test full-suite success exit=0 (3982 + 4547 + 350 passed; two
earlier runs each hit one unrelated pre-existing flake — DOM-identity,
sidebar-filter, EADDRINUSE:40301 — each passes in isolation).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@danshapiro
danshapiro merged commit a53f185 into main Jul 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants