A throwing callback (e.g. ws.send on a closed socket) would abort the
for-of loop, causing remaining subscribers to miss the data chunk and
skipping the ring buffer update — corrupting replay history for future
reconnections. Wrapping each call in try-catch keeps all subscribers
independent and ensures the buffer update always runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In the ws library, "error" is always followed by "close", so the close
handler's cleanup (clearInterval, unsubscribe sessions, unsubscribe all
terminals) was running twice. Reduced the error handler to just the
console.error log and let close handle cleanup exclusively.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace the duplicated ClientMessage/ServerMessage/SessionPatch local
type definitions in mux-websocket.ts with a single import type from
src/lib/mux-protocol.ts — eliminates the risk of the two copies drifting
apart as the protocol evolves
- Add ws.readyState === WebSocket.OPEN guard to the ws.send call inside
the terminal-operation catch block, consistent with all other sends in
the file; prevents a throw that would bubble into the outer catch and
replace the real error with a misleading "Invalid message format"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SessionBroadcaster.connect(): capture controller in a local variable and
only clear this.abortController in finally if it still equals the local
controller; prevents a concurrent connect() call's controller being
nullified by an older connect()'s finally block
- Heartbeat: send ws.ping() before incrementing missedPongs so all
MAX_MISSED_PONGS pings are actually transmitted before terminating
(previously terminated after MAX_MISSED_PONGS-1 sent pings)
- Remove TerminalManager.close(): never called by the mux handler (which
uses per-subscriber unsubscribe instead) and subtly broken — it killed
the PTY without clearing subscribers, which would have triggered an
immediate re-attach via onExit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move buffer send + subscribe inside !subscriptions.has(id) guard so
reconnecting clients don't receive the history replay twice (once on
first open, once after wsRef is reassigned on reconnect)
- Remove `terminals` field from MuxContextValue and useMemo — it was
populated from a write-only ref and never consumed by any component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- useSessionEvents: mux-active SSE cleanup now also resets
pendingMembershipKeyRef and refreshingRef so the aborted fetch's
.finally() handler cannot reschedule after unmount
- DirectTerminal: add distinct "Disconnected" label and error-coloured
dot for muxStatus "disconnected" (WebSocket constructor failure with
no reconnect), instead of the misleading "Connecting…" shown for
transient states
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The SSE effect had muxSessions (array reference) in its deps, causing
it to re-run on every snapshot and fire cleanup that cleared the
debounce timer the mux effect intentionally preserved.
- Derive muxActive = muxSessions !== undefined and use that in SSE
effect deps — the effect now only re-runs when mux transitions
between present/absent, not on every new array reference
- Add reschedule in scheduleRefresh .finally() abort path: when a
fetch is aborted mid-flight (by a new snapshot) and there is still
a pending membership key, reschedule so the refresh isn't silently
dropped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- MuxProvider: handle exited and error terminal messages — exited
removes the terminal from openedTerminalsRef (preventing re-open on
reconnect) and writes a red notice into the xterm stream; error is
logged to console
- MuxProvider: remove dead buffersRef/bufferBytesRef — client-side
ring buffer was never read; the server already delivers history on
open
- mux-websocket: use ws.terminate() instead of ws.close() on heartbeat
timeout — an unresponsive peer won't complete the close handshake,
so terminate() immediately destroys the socket and frees resources
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removing the muxSessions.length === 0 guard so an empty snapshot
triggers the membership-key comparison and scheduleRefresh(), which
fetches the full session list and dispatches a reset. Previously,
[] caused an early return that left removed sessions visible indefinitely.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- MuxProvider: reset isDestroyedRef to false at the start of the
effect so React StrictMode's double-invoke doesn't permanently break
the connection (cleanup sets it true, re-run must reset it)
- DirectTerminal: remove dead status state — it was only set to "error"
in a catch block but never read; displayStatus already derives the
error indicator from the error string directly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- mux-websocket: wire up the exited ServerMessage — add exitCallbacks
to ManagedTerminal, pass onExit param through subscribe(), and fire
callbacks when PTY exits and re-attach fails so clients get notified
- DirectTerminal: fix displayStatus to show "error" when there is a
local error (e.g. xterm.js load failure) regardless of mux state
- useSessionEvents: SSE effect's mux early-return path now returns a
cleanup function that clears refreshTimerRef and aborts in-flight
requests on unmount, preventing post-unmount dispatch calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- DirectTerminal: remove stale muxStatus read from xterm setup effect
(muxStatus was captured at mount, leaving reload button permanently
disabled); reload button now checks muxStatus directly on each render
- TerminalManager: kill PTY and delete map entry when last subscriber
unsubscribes, preventing orphaned node-pty processes when all mux
clients disconnect
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- MuxProvider: assign wsRef.current immediately after construction so
cleanup can close a WebSocket that is still in CONNECTING state;
add isDestroyedRef to prevent the close handler from scheduling
reconnects after the component unmounts
- mux-websocket: client "close" message now only unsubscribes that
client — removed terminalManager.close() which killed the shared PTY
for every other connected client
- useSessionEvents: mux effect cleanup no longer clears the debounce
timer; only aborts in-flight requests, preventing rapid mux snapshots
from starving the membership-change refresh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace Node.js Buffer.byteLength() with TextEncoder in MuxProvider
(browser-safe UTF-8 byte counting for the ring buffer)
- Remove write-only sessionSubscribedRef from MuxProvider
- Remove dead DirectTerminalLocation, DirectTerminalWsUrlOptions,
buildDirectTerminalWsUrl and their tests (superseded by mux)
- Call closeTerminal(sessionId) on DirectTerminal unmount so PTY
processes are released and openedTerminalsRef stays accurate
- Add cleanup return to mux effect in useSessionEvents so pending
refresh timers and abort controllers are cleared on unmount
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use native WS ping frames for heartbeat so idle browser clients are not
incorrectly disconnected (browser auto-responds to native ping with pong)
- Pass runtime config to buildMuxWsUrl() so dynamic port/proxy path set via
TERMINAL_WS_PATH env var is actually used (was fetched but never consumed)
- Delay initial WS connect until runtime config fetch resolves, preventing
race condition on first load
- shutdown() now terminates all mux clients and closes the WSS, preventing
orphaned PTY processes on restart
- ws 'error' handler now unsubscribes terminal callbacks alongside session
subscription to prevent leaks in edge cases where 'close' does not follow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace three separate real-time channels (per-terminal WS, SSE, HTTP poll)
with a single persistent multiplexed WebSocket at /mux.
Architecture:
- Browser ↔ MuxProvider owns one WS connection per tab (/mux)
- Terminal I/O, resize, open/close all flow over mux channels
- Session status patches delivered via a shared SSE relay:
mux server subscribes once to Next.js /api/events (SSE) and
broadcasts to all connected browser clients — no per-client polling
- Manual WS upgrade routing fixes ws library limitation with multiple
WebSocketServer instances on the same HTTP server
Remove:
- terminal-websocket.ts (legacy ttyd-based per-session server, port 14800)
- Per-terminal WebSocket connections from DirectTerminal
- Per-client 5 s HTTP polling for session patches
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements client-side multiplexed WebSocket context:
- Create MuxProvider component with persistent connection management
- Implement exponential backoff reconnection (1s to 30s)
- Add subscribeTerminal/writeTerminal/resizeTerminal/openTerminal methods
- Manage per-terminal ring buffers (50KB max) for background output
- Track session patches from server
- Create useMux() hook for easy access in components
- Mount MuxProvider in app root layout via Providers wrapper
- Support dynamic runtime config from /api/runtime/terminal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements backend multiplexed WebSocket server on /mux endpoint:
- Add mux-protocol.ts with ClientMessage and ServerMessage types
- Create TerminalManager class for managing PTY processes independently
- Implement mux-websocket.ts with attachMuxWebSocket() function
- Wire mux server into existing direct-terminal-ws server
- Support multiple terminals over single persistent WebSocket connection
- Implement 50KB ring buffer for background terminal output
- Add heartbeat and reconnection logic
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: event-driven live tab titles and favicons via SSE
Switch tab titles and favicons from polling to real-time SSE updates:
- Extend useSessionEvents to expose sseAttentionLevels map from SSE
snapshots (server-computed, includes full PR state)
- Refactor DynamicFavicon to use SSE attention levels instead of
recomputing from the full sessions array (which has stale PR data
between refreshes)
- Add useSSESessionActivity hook for session detail page to update
document.title emoji immediately on activity change
- Add live dashboard title showing count of sessions needing attention
- Update PullRequestsPage to use new DynamicFavicon API
Closes#115
* fix: seed initial attention levels to avoid stale favicon/title on first render
Accept initialAttentionLevels parameter in useSessionEvents so callers
can seed the attention map from initialSessions via getAttentionLevel().
This prevents the favicon and dashboard title from briefly showing
"all clear" before the first SSE snapshot arrives.
* fix: add EventSource mock to session page test for SSE hook compatibility
* fix: reset stale activity state when sessionId changes in useSSESessionActivity
Add sessionId to the effect dependency array and reset state to null at
the start of each effect run so callers that reuse the hook with a
different sessionId don't see the previous session's activity.
* fix: reset sseAttentionLevels on initialSessions change to prevent stale data on project switch
The reset action now accepts an optional sseAttentionLevels field. When
initialSessions changes (e.g., project switch via sidebar), the dispatch
passes the current initialAttentionLevels via ref so the favicon and
dashboard title reflect the new project immediately rather than showing
stale attention data until the first SSE snapshot.
* feat: add CI failure detail notifications in lifecycle manager
When CI fails on a PR, the lifecycle manager now fetches individual check
details (names, statuses, URLs) and sends them to the worker session.
This complements the existing static reaction message with actionable
debugging information.
Flow:
- On first transition to ci_failed: static reaction message fires (existing)
- On next poll: detailed CI failure info with check names and URLs dispatched
- Fingerprinting prevents re-sending the same failure set
- New/changed failures trigger fresh detailed notifications
- Tracking metadata cleared when PR is merged/closed or CI passes
Follows the same deduplication pattern as maybeDispatchReviewBacklog().
* fix: send CI details directly to avoid consuming reaction retry budget
The detailed CI failure dispatch now uses sessionManager.send() directly
instead of executeReaction(), so it doesn't increment the ci-failed
reaction tracker. This prevents low retries/escalateAfter settings from
causing premature escalation before the agent receives failure details.
The transition reaction still owns escalation; the detailed dispatch is
purely informational follow-up delivery.
* feat: add merge conflict notifications in lifecycle manager
Adds maybeDispatchMergeConflicts() that detects merge conflicts from
the PR enrichment cache or getMergeability() and notifies the worker
session. Conflicts are dispatched independently of session status since
they can coexist with ci_failed, changes_requested, etc.
- Uses the existing merge-conflicts reaction config
- Dispatches once per conflict occurrence (tracks lastMergeConflictDispatched)
- Clears tracking when conflicts resolve, allowing re-dispatch if they recur
- Sends directly via sessionManager.send() (same pattern as CI details)
* fix: use CICheck type instead of inline type declaration
Replace inline Array<{ name, status, url, conclusion }> with the
existing CICheck type from ./types.js for formatCIFailureMessage and
the checks variable in maybeDispatchCIFailureDetails.
* fix: resolve no-useless-assignment lint error in merge conflict check
Declare hasConflicts without initial value since both branches of the
if/else assign to it before it's read.
* feat: show agent notification state in session page blockers
The blockers section on both SessionDetail (IssuesList) and SessionCard
(alert pills) now shows whether the agent has been notified about each
blocker. Reads lifecycle manager dispatch metadata:
- lastCIFailureDispatchHash for CI failures
- lastMergeConflictDispatched for merge conflicts
- lastPendingReviewDispatchHash for review comments
Displays "agent notified" indicator next to blockers where the lifecycle
manager has already forwarded the issue to the worker session.
* fix: use lifecycle status as fallback for blockers when PR data is stale
The blockers section now uses the lifecycle manager's session status
metadata as a source of truth when PR enrichment data hasn't caught up.
PR enrichment uses a 5-min cache and can timeout or be rate-limited,
causing blockers to show stale/incorrect state.
Changes:
- IssuesList and getAlerts now check metadata["status"] (lifecycle
manager state) alongside PR enrichment data
- CI failing: shown when pr.ciStatus is "failing" OR lifecycle status
is "ci_failed"
- Changes requested: shown when pr.reviewDecision matches OR lifecycle
status is "changes_requested"
- Merge conflicts: shown when pr.mergeability.noConflicts is false OR
lifecycle dispatch metadata indicates conflicts were detected
* fix: fall back to getMergeability when cached hasConflicts is undefined
When PREnrichmentData has hasConflicts as undefined (the field is typed
as boolean | undefined), the previous check treated it as no conflicts.
Now falls through to the getMergeability() call instead.
* test: add coverage for CI/conflict notify action and recovery paths
- Test CI tracking clears when CI recovers to passing
- Test notify action for CI failure details (human notification path)
- Test notify action for merge conflicts (human notification path)
These cover the previously uncovered notify action branches and the
CI recovery cleanup path in the lifecycle manager.
* fix: resolve typecheck error in CI recovery test
writeMetadata requires SessionMetadata type which doesn't include
custom keys like lastCIFailureFingerprint. Rewrote the test to use
setupCheck and let the lifecycle manager set tracking metadata
naturally through the CI failure flow, then verify cleanup on recovery.
* fix: don't use dispatch metadata for conflict detection in UI
lastMergeConflictDispatched lingers after conflicts resolve until the
lifecycle manager's next poll clears it. Using it as a conflict signal
caused stale "merge conflict" alerts. Now only pr.mergeability.noConflicts
drives conflict detection; the metadata is only used for the "agent
notified" badge.
* fix: use Promise.allSettled for dispatch functions to avoid orphaned rejections
Promise.all rejects immediately on first failure, leaving in-flight
promises unmonitored. Promise.allSettled waits for all to complete.
* feat: standardize agent plugins with shared hooks, activity JSONL, and CLAUDE.md
- Add CLAUDE.md with full project context and agent plugin implementation standards
- Extract shared PATH-wrapper metadata hooks into @composio/ao-core (agent-workspace-hooks.ts)
- Backfill Aider + OpenCode: setupWorkspaceHooks, postLaunchSetup, getSessionInfo, getRestoreCommand
- Add recordActivity method to Agent interface for terminal-derived JSONL writing
- Create activity-log.ts in core: appendActivityEntry, readLastActivityEntry
- Lifecycle manager calls recordActivity before getActivityState for agents that implement it
- Upgrade detectActivity in Aider/OpenCode with real terminal prompt/permission patterns
- Upgrade Codex getActivityState to parse JSONL entry types (6 states, up from 2)
- Replace duplicated normalizePermissionMode with shared normalizeAgentPermissionMode from core
- Remove ~200 lines of duplicated shell wrapper code from Codex plugin
- Add git wrapper detection for existing branch switches (parity with Claude Code hooks)
- 484 tests passing across all 4 agent plugins
* chore: unignore CLAUDE.md and AGENTS.md, slim down AGENTS.md to pointer
- Remove CLAUDE.md and AGENTS.md from .gitignore (both should be tracked)
- Slim AGENTS.md from 96 to 30 lines — commands, TL;DR, key files
- Full context now lives in CLAUDE.md; AGENTS.md points there
* fix: eliminate redundant double file read in readLastActivityEntry
Remove the readLastJsonlEntry call that was only used as a null-check,
then immediately discarded before re-reading the same file from scratch.
Now performs a single open + stat + tail-read per poll cycle.
* fix: remove duplicate case-insensitive regex in OpenCode detectActivity
The \(y\)es.*\(n\)o pattern with /i flag was identical to the preceding
\(Y\)es.*\(N\)o/i check — remove the redundant line.
* fix: use zero-initialized buffer and slice to bytesRead in readLastActivityEntry
Replace Buffer.allocUnsafe with Buffer.alloc and slice the result to
actual bytesRead, preventing uninitialized heap data from being parsed
if the file shrinks between stat() and read().
* fix: add staleness cap for waiting_input/blocked and deduplicate recordActivity
- Add ACTIVITY_INPUT_STALENESS_MS (5 min) cap so stale waiting_input/blocked
entries don't keep sessions stuck in needs_input on the dashboard forever.
- Extract checkActivityLogState() into core — shared by aider, opencode, codex.
- Extract classifyTerminalActivity() into core — deduplicates the identical
recordActivity logic across all three plugins.
* fix: prioritize native Codex JSONL over AO activity log in getActivityState
Reorder detection so Codex's native 6-state JSONL (approval_request,
error, tool_call, etc.) is checked first. AO activity JSONL from
terminal parsing is now a fallback only for waiting_input/blocked states
that the native JSONL may not have captured. Previously the AO log was
always fresh (written every poll cycle by recordActivity) and shadowed
the richer native detection entirely.
* fix: restrict AO activity JSONL to waiting_input/blocked only
checkActivityLogState now only returns results for waiting_input/blocked
states. Non-critical states (active/ready/idle) return null, forcing
callers to fall through to their native signals (git commits, chat
history, OpenCode API, Codex native JSONL). This prevents the lifecycle
manager's recordActivity writes (which refresh mtime every poll cycle)
from shadowing richer detection methods and breaking stuck-detection.
* fix: prevent stale idle timestamp in aider and skip flags in git wrapper
- Remove Aider's fallback that returned idle with activityResult.modifiedAt
(always fresh due to recordActivity writes). Now returns null when no
git commits or chat history are found, letting the lifecycle manager
handle stuck-detection correctly.
- Fix git wrapper catch-all case to skip flag arguments (e.g. -B) and
look at $3 for the actual branch name.
* fix: add mtime fallback for empty Codex JSONL and remove unused exports
- When the native Codex session file exists but readLastJsonlEntry returns
null (empty/unparseable), fall back to stat-based mtime detection instead
of losing activity detection entirely.
- Remove unused exports getActivityLogPath and ACTIVITY_INPUT_STALENESS_MS
from @composio/ao-core barrel — they are only used internally.
* fix: opencode activity state detection and CLAUDE.md agent plugin standards
- Fix session ID capture to handle both session_id (snake_case) and
sessionID (camelCase) from OpenCode 1.3.x JSON responses
- Replace broken --command true with -- noop for session creation
(true is not a valid OpenCode command since 1.3.x)
- Add JSONL mtime fallback in getActivityState so active/ready/idle
states work even when findOpenCodeSession returns null
- Rewrite CLAUDE.md activity detection section with the full
getActivityState contract, mandatory JSONL mtime fallback pattern,
and 8 required tests every agent plugin must implement
* fix: opencode --command true flag and activity JSONL mtime staleness
- Fix opencode getLaunchCommand to use `--command true` instead of `-- noop`
(aligns with test expectations and opencode CLI docs)
- Fix checkActivityLogState to use entry.ts instead of file mtime for
staleness checking — recordActivity refreshes mtime every poll cycle,
which prevented stale waiting_input/blocked entries from being detected
- Fix opencode getActivityState fallback to use entry state directly
instead of re-deriving from mtime, which always returned "active"
because recordActivity refreshes the file every cycle
- Update tests to reflect new entry-state-based fallback behavior
* fix: deduplicate recordActivity writes and restore mtime-based fallback
recordActivity was writing to the JSONL every poll cycle (~30s), which
kept refreshing the file mtime and prevented the JSONL mtime fallback
in getActivityState from ever reaching "ready" or "idle".
Fix: skip writes when the state hasn't changed and the last entry is
<20s old. This keeps mtime fresh during active work (writes every
20-30s, within the 30s activeWindow) but lets it age naturally when
the agent goes quiet.
Also restores the mtime-based age classification in the JSONL fallback
(active/ready/idle by mtime age) instead of returning the entry state
directly, which was always "active" since that's what recordActivity
writes.
Applied to both OpenCode and Aider plugins. Updated CLAUDE.md with
the dedup pattern and rationale.
* fix: align integration tests with opencode `-- noop` launch command
Update 9 test expectations from `--command true` to `-- noop` to match
the reverted getLaunchCommand implementation.
* fix: add JSONL mtime fallback to Aider getActivityState
When git commits and chat history are both unavailable (e.g. early
session startup), Aider's getActivityState now falls back to the AO
activity JSONL mtime for active/ready/idle classification — matching
OpenCode's existing step 3 fallback. Previously it returned null,
leaving the dashboard with no activity signal.
* fix: add write deduplication to Codex recordActivity
Add the same dedup logic that Aider and OpenCode already have — skip
writes when the state hasn't changed and the last entry is recent
(<20s). Prevents unbounded file growth and stale mtime refreshes.
* refactor: extract shared recordTerminalActivity into core
Move the duplicated recordActivity logic (classify + dedup + append)
from all three plugins into a shared `recordTerminalActivity` function
in `@composio/ao-core/activity-log`. Each plugin's `recordActivity`
is now a thin wrapper that delegates to the shared function.
Add core tests for classifyTerminalActivity, checkActivityLogState,
and recordTerminalActivity (10 tests).
* fix: validate JSONL entry fields, handle invalid dates, consistent operators
- Validate required fields (ts, state, source) before casting parsed
JSON to ActivityLogEntry — prevents malformed entries from propagating
- Guard against invalid Date parsing in checkActivityLogState — returns
null instead of comparing against NaN
- Use <= instead of < in Aider chat-history threshold comparisons to
match OpenCode and Aider's own JSONL fallback path
* fix: use --command true for opencode run, validate sed key parameter
- Replace `-- noop` with `--command true` in opencode getLaunchCommand
so the bootstrap uses a valid command
- Validate metadata key against [a-zA-Z0-9_-]+ in the git wrapper's
update_ao_metadata to prevent sed metacharacter injection
* fix: extract DEFAULT_ACTIVE_WINDOW_MS constant, clarify Codex recordActivity
- Extract magic number 30_000 into DEFAULT_ACTIVE_WINDOW_MS constant in
core types, used by Aider and OpenCode for active/ready thresholds
- Clarify in CLAUDE.md that Codex implements recordActivity as a safety
net for when its native JSONL is missing/unparseable, not redundantly
* fix: add JSONL mtime fallback to Codex getActivityState
When native Codex session file is missing but AO JSONL has data,
derive active/ready/idle from JSONL mtime instead of returning null.
Matches the fallback pattern already in Aider (step 4) and OpenCode
(step 3).
* fix: validate ActivityState and source values when parsing JSONL entries
Validate that `state` is one of the known ActivityState values and
`source` is "terminal" or "native" before constructing the entry.
Construct the entry explicitly instead of using unsafe double cast.
* fix: wrap getOutput + recordActivity in try-catch to protect getActivityState
If runtime.getOutput() throws (e.g. tmux unresponsive), the error
previously propagated to the outer catch, skipping getActivityState
entirely. Now the entire recordActivity preamble is wrapped in its
own try-catch so getActivityState always runs.
* chore: add .ao/ to gitignore
* chore: clean up gitignore comment
* test: add coverage for activity-log and agent-workspace-hooks
- activity-log: test readLastActivityEntry (missing file), invalid
entry.ts in checkActivityLogState, blocked state path
- agent-workspace-hooks: test buildAgentPath (dedup, defaults,
ordering), setupPathWrapperWorkspace (create/skip wrappers,
AGENTS.md create/skip)
Raises diff coverage from 39% toward 80% threshold.
* test: add real file I/O tests for readLastActivityEntry and recordTerminalActivity
Test readLastActivityEntry with actual temp files: valid entries,
empty file, invalid JSON, invalid state, missing fields, multi-line.
Test recordTerminalActivity dedup logic and actionable state bypass.
* fix: add activeWindow threshold to Codex native JSONL state detection
Action entries (tool_call, user_input, exec_command) now use the 30s
active window: <=30s is "active", 30s-5min is "ready", >5min is "idle".
Previously these skipped "ready" entirely, going straight from "active"
to "idle" at the 5min threshold.
* fix: add activeWindow threshold to Claude Code native JSONL state detection
Same fix as Codex — action entries (user, tool_use, progress) now use
the 30s active window for consistent 3-state classification across
all agent plugins: <=30s active, 30s-5min ready, >5min idle.
* fix: handle truncated JSONL, add exited state, fix session lookup and dedup window
- readLastActivityEntry: increase tail buffer to 4KB, skip truncated
first line when reading from offset, try lines from end on parse error
- Add "exited" to valid ActivityState set in JSONL validation
- findOpenCodeSession: pick most recently updated session when multiple
title matches exist, preventing stale session binding
- Increase dedup window from 20s to 60s so mtime can age past the 30s
active window between writes, allowing "ready" state to be reached
* fix: use entry state for JSONL fallback, write AGENTS.md to .ao/, revert dedup to 20s
- Replace mtime-based active/ready/idle derivation in all 3 plugin
fallbacks with direct entry.state + entry.ts usage. This eliminates
the fundamental conflict between write deduplication and mtime
freshness — the entry already has the correct detected state.
- Revert dedup window to 20s (purely I/O optimization, no longer
affects state detection since mtime is not used)
- Write AO session context to .ao/AGENTS.md (gitignored) instead of
modifying the repo-tracked AGENTS.md, preventing dirty worktree state
* fix: reorder Codex fallback chain so AO JSONL is checked before stat mtime
When native JSONL exists but can't be parsed, the stat() fallback
previously returned early, skipping AO JSONL waiting_input/blocked
detection and the ready state. Now it falls through to AO JSONL first,
then uses stat mtime as a last resort with proper 3-state classification.
* fix: re-validate canonicalized ao_dir against trusted roots
After resolving symlinks with pwd -P, re-check real_ao_dir against
the trusted root allowlist. Prevents paths like /tmp/../../home/user
from passing the pre-canonicalization check then escaping to arbitrary
directories after symlink resolution.
* fix: update tests for .ao/AGENTS.md location and remove unused vi import
- Update Codex setupWorkspaceHooks tests to expect .ao/AGENTS.md
instead of workspace root AGENTS.md
- Remove unused vi import from activity-log test (lint error)
* fix: add age-based decay to JSONL entry fallback via getActivityFallbackState
Extract getActivityFallbackState in core — reclassifies entry state
based on entry.ts age (active→ready→idle) so old entries don't stay
as "active" forever when recordActivity stops being called. All three
plugins now use this shared helper for their JSONL fallback paths.
* fix: apply staleness cap to actionable states in getActivityFallbackState
Stale waiting_input/blocked entries (older than ACTIVITY_INPUT_STALENESS_MS)
are now treated as idle in the fallback, preventing them from bypassing
the staleness filtering in checkActivityLogState.
* fix: respect entry state as ceiling in getActivityFallbackState
Age-based decay can only demote (active→ready→idle), never promote.
A fresh "idle" entry stays "idle" instead of being reclassified as
"active" — the detected state from terminal output is authoritative.
* docs: update CLAUDE.md to match current activity detection implementation
- Replace inline recordActivity dedup example with recordTerminalActivity delegation
- Replace mtime-based fallback example with getActivityFallbackState
- Update step 4 description: entry state + age-based decay, not mtime
- Add new core exports to utilities section
- Document .ao/AGENTS.md location and setupPathWrapperWorkspace
- Update required test list for entry-based fallback
* fix: skip metadata helper rewrite when version marker matches
Move ao-metadata-helper.sh write inside the needsUpdate check so it's
only rewritten when wrapper scripts are outdated, not on every call.
* fix: update Codex test for metadata helper skip when version matches
Metadata helper is now inside the needsUpdate check, so when the
version marker matches, no wrapper writes occur (including helper).
The --interval flag is only relevant when --watch is enabled.
Previously, passing `--interval 0` without `--watch` threw an error,
whereas `--interval 5` was silently ignored. Now, invalid intervals
are ignored when `--watch` is omitted, ensuring consistent behavior.
Also adds a test case to prevent regression.
Cover the following previously-uncovered lines in status.ts:
- 65-69: maybeClearScreen() isTTY branch
- 255-256: maybeClearScreen() call on watch refresh (refreshing=true)
- 262-266: loadConfig() throw → fallback to tmux session discovery
- 269-271: unknown --project flag → process.exit(1)
- 388, 390-396, 398: tracker block (registry.get, listIssues, error catch)
- 402-405: unverified issues warning banner
- 424-436: setInterval guard skips render when already in progress
Also hoists mockGetPluginRegistry as a vi.fn() so individual tests
can override the plugin registry returned by getPluginRegistry.