Skip to content

feat: truly-idle alerting — server-authoritative terminal.idle bell/shade + persistent green#524

Merged
danshapiro merged 8 commits into
mainfrom
feat/truly-idle-alerting
Jul 24, 2026
Merged

feat: truly-idle alerting — server-authoritative terminal.idle bell/shade + persistent green#524
danshapiro merged 8 commits into
mainfrom
feat/truly-idle-alerting

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Truly-idle alerting semantics (Track 4)

The bell used to ring on every terminal.turn.complete — including subagent finishes and turn ends with another prompt already queued. This PR makes the bell/shade edge for terminal-mode CLI panes (claude/codex/opencode/amplifier) a server-authoritative truly-idle edge, and makes green a persistent idle state. Fresh-agent (fresh*) panes are untouched.

Pinned wire contract (shared with the Rust track)

  • Busy/blue: existing activity protocol, unchanged.
  • terminal.turn.complete: still emitted at turn boundaries; the client no longer rings/shades on it for terminal CLI panes.
  • NEW terminal.idle: { terminalId, at (server epoch ms), reason: 'grace' | 'queue-empty' } — once per busy→truly-idle transition, after a 2000ms grace (TERMINAL_IDLE_GRACE_MS) with no new session-file activity AND no queued prompt; never after crash/interrupt/exit; subagent completions mid-turn never produce it.

Server (legacy Node)

  • server/coding-cli/truly-idle-emitter.ts: provider-agnostic per-terminal state machine fed by each tracker's existing changed + turn.complete streams. One-shot unref'd grace timers armed only at turn boundaries — zero polling, no per-pane intervals (chokidar untouched; the amplifier events tailer is reused, not rewritten — no new file watching was needed at all: the queued/subagent knowledge already lives in the tracker state machines).
  • Wired for all four trackers in server/index.tswsHandler.broadcastTerminalIdle (disposed on shutdown).

Client

  • Blue = busy (unchanged).
  • Green = persistent idle (resolvePaneIdleGreen): shown when the pane's CLI session is known (activity record, bound sessionRef, or resume id) and not busy. Replaces one-shot needs-attention green for the four CLI panes; fresh-agent panes keep the attention green.
  • Bell + tab shading: fire ONLY on a terminal.idle edge; deduped per terminal by monotonic at in its own namespace (lastIdleAtByTerminalId), reset on real server restart. Never on turn.complete; never on crash/exit (TERM-18 preserved — trackers remove state on exit and the emitter cancels + never emits).
  • Shade-clear paths unchanged (tab activation / click-in-tab via dismissTabGreen; 'type' mode untouched).
  • Removed: client terminal.turn.complete bell path, latestTurnCompletions replay-to-green loops, completionSeq dedupe machinery, and client BEL minting for all four CLI modes (isServerAuthoritativeTurnCompleteMode now covers opencode + amplifier; custom CLI modes keep the BEL path).

Legacy-health findings per CLI (Task 1)

  • claude: healthy — PTY submit→busy, Stop-hook BEL→idle+completion, deadman self-heal (unit suite green).
  • codex: healthy — app-server turn events + JSONL reconcile + BEL, queued-submit re-arm to pending, completion only when landing idle (unit suite green). The user-reported "codex/amplifier never turn blue" was NOT reproducible against the legacy trackers' unit/ws suites; consistent with it being the Rust server's gap (owned by another track).
  • amplifier: healthy — events.jsonl tailer→reducer→tracker with provisional-busy grace + force-read backstop (unit suite green).
  • opencode: terminal mode DOES have a tracker (opencode-activity-tracker.ts, SSE ownership reducer, busy-only records + turn.complete) — healthy.
  • No legacy fixes were required; 1018 coding-cli unit tests green at base and after changes.

Queued/subagent detection per CLI (Task 2)

  • claude: tracker in-flight submit/BEL ledger — phase stays busy while queued turns remain, so mid-queue completions arrive "while busy" → emitter holds the bell; final drain emits reason: 'queue-empty'. Subagent stops don't fire the Stop hook; stray BELs are filtered (countTrackerTurnCompleteSignals) and BELs while idle are ignored.
  • codex: tracker queuedSubmitAt re-arms busy→pending at turn clear (emitter treats it as queue evidence) and only records a completion when landing idle — queue drained by construction.
  • amplifier: prompt:submit/prompt:complete records via the existing incremental tailer; a queued prompt starts a new prompt:submit inside the grace window → cancels. Subagent files are rejected by the locator/reducer (session:fork, parent_id) and never feed the tracker.
  • opencode: grace-only fallback (no queue signal available; parallel track researching).

Suppression cases proven (test names)

Emitter (test/unit/server/coding-cli/truly-idle-emitter.test.ts):

  • back-to-back turns → suppresses the bell between back-to-back turns and emits once at the very end
  • queued prompt at turn end → holds the bell while a queued prompt keeps the terminal busy, then emits queue-empty after the queue drains, treats a codex busy->pending re-arm as queue evidence
  • crash/exit → never emits after a crash/exit (activity remove), even with a grace timer armed (+ ws-level does not broadcast terminal.idle when the terminal exits inside the grace window in test/server/ws-terminal-idle.test.ts)
  • deadman/signal loss (covers subagent-free fabrications) → never emits on a deadman/signal-loss idle flip…, never emits on a codex busy->unknown deadman flip
    Client:
  • turn.complete never bells → never bells or shades on terminal.turn.complete (informational only for terminal CLI panes) (test/e2e/turn-complete-notification-flow.test.tsx), ignores latestTurnCompletions in activity list responses… (App.ws-bootstrap.test.tsx)
  • reconnect replay → rings exactly once when a reconnect replays the same terminal.idle edge; slice-level dedupes idle edges per terminal by monotonic at…
  • both shade-clear paths → bells and highlights on background completion, clears on tab click (default click mode), click mode: clicking the already-active tab clears attention, type mode: attention persists after tab switch, clears on terminal input
  • persistent green → shows persistent idle green on a claude pane header whenever the session is known and not busy (pane-activity-indicator-flow.test.tsx) + resolvePaneIdleGreen unit matrix (pane-activity.test.ts)

Playwright (legacy-chromium, passing 2x)

test/e2e-browser/specs/truly-idle-alerting.spec.ts — fake CLAUDE_CMD CLI drives the full production pipeline (no Redux shortcuts): PTY submit → blue; BEL + quiet 2s grace → terminal.idle → persistent green header + tab shade + exactly one alert edge (stable across a settle window); activating the tab clears the shade while green persists. Added to MATRIX_SPECS; the rust leg is test.fixme annotated "pending rust terminal.idle emitter — feat/rust-terminal-activity-idle" so it flips on trivially.

Verification

  • Typecheck (client + server tsconfig) clean; npm run lint 0 errors (10 pre-existing warnings in untouched files).
  • Focused suites green 2x (1904 tests each run); PW spec green 2x (25.5s / 26.1s).
  • Coordinated npm test: client 3982 passed, server 4547 passed, electron 350 passed — all green.

Deferred

  • Amplifier/codex/opencode Playwright legs (claude leg covers the shared pipeline end-to-end; per-CLI queue semantics are unit-proven).
  • Rust-server terminal.idle emitter + flipping the PW rust leg on (owned by feat/rust-terminal-activity-idle).
  • docs/index.html: untouched — the mock has no attention/indicator visuals to update; the default visual experience is unchanged.

🤖 Generated with Amplifier

codex and others added 8 commits July 23, 2026 23:44
Pinned contract shared with the Rust server port:
{ terminalId, at (server epoch ms), reason: 'grace' | 'queue-empty' }.

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

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ne-shot grace

Provider-agnostic emitter fed by each activity tracker's 'changed' +
'turn.complete' streams. Turn boundary landing idle arms a one-shot 2s
grace timer (cancelled by any busy flip); a boundary while still busy is
a queued turn (hold the bell, reason queue-empty on drain); deadman /
signal-loss idle flips and PTY exit/crash removals never emit. Zero
polling: one-shot unref'd timers only.

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

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

wireTrulyIdleEmitter on claude/codex/amplifier/opencode trackers ->
wsHandler.broadcastTerminalIdle. Wirings disposed on shutdown.

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

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

Dedicated at-monotonic dedupe namespace (lastIdleAtByTerminalId) so idle
edges can never poison (or be poisoned by) turn-complete baselines; reset
alongside the completion baselines on a real server restart.

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

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

- App.tsx folds terminal.idle in via applyServerIdle; the terminal.turn.complete
  handler and the latestTurnCompletions replay loops are removed (they minted
  false bells on subagent finishes and mid-queue turn ends).
- All four terminal CLI modes (claude/codex/opencode/amplifier) are now
  server-authoritative for turn completion: the client BEL path no longer
  mints completions for them (custom CLI modes keep it).
- completionSeq dedupe machinery removed from the slice/persistence — the
  idle edge dedupes by monotonic at; fresh-agent edges keep the at regime.

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

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
resolvePaneIdleGreen: green when the pane's CLI session is known (activity
record, bound sessionRef, or resume id) and the pane is not busy. Replaces
the one-shot needs-attention green for claude/codex/opencode/amplifier
panes; fresh-agent panes keep the attention-based green. Independent of
tab shading and its click-clearing.

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

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

Fake CLAUDE_CMD CLI drives the full pipeline with zero Redux shortcuts:
PTY submit -> blue; BEL + quiet 2s grace -> terminal.idle -> persistent
green header + tab shade + exactly one alert edge; activating the tab
clears the shade while green persists. Added to MATRIX_SPECS; the rust
leg is test.fixme pending the rust terminal.idle emitter
(feat/rust-terminal-activity-idle).

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

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

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

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@danshapiro
danshapiro merged commit cc96c93 into main Jul 24, 2026
1 check passed
danshapiro pushed a commit that referenced this pull request Jul 24, 2026
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>
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