* feat: support multiple concurrent orchestrators with isolated worktrees
- Each orchestrator session gets a numbered ID ({prefix}-orchestrator-N)
and an isolated git worktree, replacing the single shared orchestrator
- reserveNextOrchestratorIdentity atomically reserves the next available
number and detects conflicting project prefix configurations early
- isOrchestratorSession / isOrchestratorSessionName accept optional
allSessionPrefixes for cross-project false-positive prevention
- getProjectPause and resolveGlobalPause track the longest active pause
across all concurrent orchestrators instead of returning the first found
- cleanupWorktreeAndMetadata helper used consistently on all failure paths
including post-launch; system prompt file included in cleanup
- pollBacklog, status.ts, ProjectSidebar, sessions page, global-pause,
project-utils, serialize, and sessions API route all pass
allSessionPrefixes to isOrchestratorSession
- Web sessions/[id]/page.tsx fetches project prefix map and passes it
through prefixByProjectRef to avoid stale closure in fetchProjectSessions
* fix: seed sseAttentionLevels from fresh sessions on full refresh
When scheduleRefresh dispatches a reset action, derive sseAttentionLevels
from the freshly fetched sessions using getAttentionLevel. This clears
phantom entries for removed sessions and seeds correct levels for new
sessions, preventing stale favicon color and attention counts until the
next SSE snapshot.
* fix: merge duplicate @/lib/types imports into single import statement
Combining the separate import type and value import from @/lib/types
into one import to satisfy the no-duplicate-imports lint rule.
tsconfig.server.json sets rootDir:"server" so importing from
../src/lib/mux-protocol.ts violated the boundary and broke the CI
typecheck step. Reverted to local type definitions with a comment
linking to the canonical source.
Also adds tests to bring diff coverage above 80%:
- MuxProvider.test.tsx: 30 tests covering lifecycle, message handling,
and all terminal operations (subscribeTerminal, write, open, close,
resize, reconnect, cleanup)
- api-routes.test.ts: GET /api/sessions/patches coverage (success,
field shape, project filter, error path)
- useSessionEvents.test.ts: mux-path tests (muxSessions snapshot
dispatch, membership-change scheduleRefresh, mux-active SSE bypass,
cleanup on unmount)
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>
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>
- 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>
- 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>
* 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.
Exposes connectionStatus ("connected" | "reconnecting" | "disconnected")
from useSessionEvents via onopen/onerror EventSource handlers, and renders
a fixed ConnectionBar at the top of the viewport that shows a pulsing amber
bar while reconnecting and a red 32px "Offline · tap to retry" bar when
disconnected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the lazy useState initializer that read window.matchMedia on
first render (causing SSR/client hydration mismatch) with a plain
false default; the existing useEffect already syncs the correct value
after mount. Adds a full vitest test suite covering all six specified
cases (SSR-safe default, match/no-match, number-to-query conversion,
change-event reactivity, and listener cleanup on unmount).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf: coalesce dashboard session refreshes
Batch membership-driven refreshes behind a short cadence and keep low-frequency steady-state refreshes so pause and PR data stay current without hammering /api/sessions during SSE churn.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* perf: memoize dashboard session views
Keep unchanged zones and cards cold during same-membership SSE updates by stabilizing dashboard callbacks, caching project groupings, and memoizing the session view tree with a render-cadence regression test.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix: prevent stale refresh scheduling after project switch
Abort in-flight refreshes and ignore late completions from disposed effects so old project fetches cannot re-arm timers or reset the new project state.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
---------
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(web): add project-based dashboard architecture
- Add project query parameter to API routes
- Filter sessions by project in both SSR and SSE
- Update useSessionEvents hook to accept project param
- Update Dashboard and pass project to hook
- Add unit tests for project filtering
- Add architecture spec document
Implements project-based architecture as defined in docs/specs/project-based-dashboard-architecture.md:
- GET /api/sessions?project=X returns sessions for project X
- GET /api/events?project=X streams only sessions for project X
- Dashboard uses project filter from config
- SSE URL includes project param when provided
- Project matching uses projectId and sessionPrefix
- Full backward compatibility maintained (no param = all sessions)
* fix: remove duplicate export default in page.tsx
* fix(web): clean project-scoped dashboard verification
* fix(web): scope dashboard using project id
* fix(web): address Bugbot findings in project-scoped dashboard
- Consolidate triplicated matchesProject into shared lib/project-utils.ts
- Fix Dashboard to receive both projectId (for SSE filtering) and projectName (for display)
- Remove inline matchesProject definitions from page.tsx, sessions/route.ts, events/route.ts
* fix(web): resolve Bugbot issues in project-scoped dashboard
- Add ?project=all query param support in SSR page to show all sessions
(previously getPrimaryProjectId() always returned non-empty, making
else branches unreachable)
- Exclude orchestrator sessions from SSE stream to match SSR/API behavior
(orchestrator sessions get their own button, not a card)
- Add tests for SSE stream orchestrator exclusion and project filtering
Addresses Bugbot issues:
- #2903595986: Dead else branches when project filter always applied
- #2903595995: SSE stream includes orchestrator sessions unlike SSR/API
* feat(web): add project navigation sidebar
Add visible left sidebar with project navigation for multi-project setups:
- ProjectSidebar component with active state styling
- /api/projects endpoint to fetch configured projects
- getAllProjects() helper in project-name.ts
- Sidebar appears only when 2+ projects configured
- Click navigation updates ?project= query param
- Active project highlighted with accent color
Tests:
- ProjectSidebar component tests (8 tests)
- API routes tests with proper mocking
- All 386 web tests passing
Manual test steps:
1. Configure 2+ projects in agent-orchestrator.yaml
2. Start dashboard - sidebar should appear on left
3. Click different projects - URL updates, sessions filter
4. Click "All Projects" - shows all sessions across projects
5. Active project highlighted in sidebar
* fix(web): remove duplicate import in test file
* fix(web): consolidate ProjectInfo type to shared source
Remove duplicated ProjectInfo interface definitions from Dashboard.tsx and
ProjectSidebar.tsx. Both now import the type from @/lib/project-name where it is exported as the single source of truth.
This addresses Bugbot issue #2906835895: Triplicated ProjectInfo type instead of shared import.
* fix(web): integrate globalPause state from main
* fix(web): consolidate duplicate @/lib/types import
* feat(web): add project-based dashboard architecture
- Add project query parameter to API routes
- Filter sessions by project in both SSR and SSE
- Update useSessionEvents hook to accept project param
- Update Dashboard and pass project to hook
- Add unit tests for project filtering
- Add architecture spec document
Implements project-based architecture as defined in docs/specs/project-based-dashboard-architecture.md:
- GET /api/sessions?project=X returns sessions for project X
- GET /api/events?project=X streams only sessions for project X
- Dashboard uses project filter from config
- SSE URL includes project param when provided
- Project matching uses projectId and sessionPrefix
- Full backward compatibility maintained (no param = all sessions)
* fix: remove duplicate export default in page.tsx
* fix(web): clean project-scoped dashboard verification
* fix(web): scope dashboard using project id
* fix(web): address Bugbot findings in project-scoped dashboard
- Consolidate triplicated matchesProject into shared lib/project-utils.ts
- Fix Dashboard to receive both projectId (for SSE filtering) and projectName (for display)
- Remove inline matchesProject definitions from page.tsx, sessions/route.ts, events/route.ts
* fix(web): resolve Bugbot issues in project-scoped dashboard
- Add ?project=all query param support in SSR page to show all sessions
(previously getPrimaryProjectId() always returned non-empty, making
else branches unreachable)
- Exclude orchestrator sessions from SSE stream to match SSR/API behavior
(orchestrator sessions get their own button, not a card)
- Add tests for SSE stream orchestrator exclusion and project filtering
Addresses Bugbot issues:
- #2903595986: Dead else branches when project filter always applied
- #2903595995: SSE stream includes orchestrator sessions unlike SSR/API
* feat(web): add project navigation sidebar
Add visible left sidebar with project navigation for multi-project setups:
- ProjectSidebar component with active state styling
- /api/projects endpoint to fetch configured projects
- getAllProjects() helper in project-name.ts
- Sidebar appears only when 2+ projects configured
- Click navigation updates ?project= query param
- Active project highlighted with accent color
Tests:
- ProjectSidebar component tests (8 tests)
- API routes tests with proper mocking
- All 386 web tests passing
Manual test steps:
1. Configure 2+ projects in agent-orchestrator.yaml
2. Start dashboard - sidebar should appear on left
3. Click different projects - URL updates, sessions filter
4. Click "All Projects" - shows all sessions across projects
5. Active project highlighted in sidebar
* fix(web): remove duplicate import in test file
* fix(web): consolidate ProjectInfo type to shared source
Remove duplicated ProjectInfo interface definitions from Dashboard.tsx and
ProjectSidebar.tsx. Both now import the type from @/lib/project-name where it is exported as the single source of truth.
This addresses Bugbot issue #2906835895: Triplicated ProjectInfo type instead of shared import.
* fix(web): integrate globalPause state from main
* fix(web): consolidate duplicate @/lib/types import
* fix(web): restore global pause state and membership refresh
* fix(web): satisfy lint in project-scoped page defaults
* fix(web): remove dead global pause reducer action
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix(web): restore global pause resume time
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* chore(web): retrigger bugbot after thread reset
* refactor(web): centralize project session filtering helpers
* fix(web): reset pause banner dismissal on new pause
* fix(web): remove useless orchestrator assignment
* fix(web): derive header stats from live session state
* fix(web): restore project name in dashboard header
* fix(web): restore backlog poller startup in events stream
* refactor(web): keep project-utils helpers internal
* chore: retrigger bugbot evaluation
* chore: retrigger stuck bugbot check
* fix: address latest bugbot findings for dashboard events
* test(web): add dashboard bugbot regression coverage
* refactor(web): simplify projectId selection in dashboard props
* fix(web): derive selected project name from project filter
* refactor(web): initialize dashboard defaults before service load
* fix(web): satisfy lint in dashboard page error path
* fix: pause workers on model limits and stabilize session visibility
Detect model limit exhaustion, pause project worker operations until reset, and expose pause state in dashboard/API. Also harden killed-session cleanup and SSE session reconciliation so sessions do not appear ghost-active or disappear until reload.
* fix: satisfy lint in rate-limit pause probe
* fix: address bugbot feedback for pause handling
* fix(lifecycle): prevent infinite re-pause loop for duration-based rate limits
Duration-based rate limits (e.g., 'usage limit reached for N hours') were
causing infinite re-pause loops because they always calculate reset time as
Date.now() + duration, which extends the pause on every poll cycle if the
message remains in terminal output.
Now checks for existing active pause before setting a new one:
- Skips override if same session already has active pause
- Preserves longer pauses from other sessions
Fixes infinite loop described in PR #367 review comment.
* fix(core): export global pause constants to prevent duplication
Export GLOBAL_PAUSE_*_KEY constants and parsePauseUntil utility from
@composio/ao-core so web package can import them instead of hardcoding.
This prevents silent breakage if key values ever change - now there's
a single source of truth.
Addresses review comment on PR #367.
* fix(web): globalPause as first-class state in SSE event flow
globalPause is now part of the same reducer/event flow as sessions,
not derived from provider-specific output text in the UI.
Changes:
- useSessionEvents: manage globalPause alongside sessions in reducer state
- Dashboard: consume globalPause from hook instead of SSR-only prop
- types: re-export GlobalPauseState from shared lib (provider-agnostic contract)
- Tests: 15 new tests proving banner appears/disappears from state updates
alone, regardless of agent model/plugin (Claude Code, OpenCode, Codex)
Design requirements satisfied:
- First-class state in same reducer/event flow as sessions
- Key names sourced from shared core contract via export/import
- Provider-neutral: no Anthropic/OpenAI string coupling in control logic
- State-driven: banner visibility from reducer state updates via SSE
Addresses Bugbot finding: Dashboard pause banner never updates after
initial render (eba24a0b-9e4c-47e3-91c9-7d10be01e3cf)
* fix: remove unused import in test file
* fix(cli): consistent purge default for session kill and stop commands
The kill method's default changed from opt-in (=== true) to opt-out (!== false).
Both session kill and stop commands now use the same logic:
purgeOpenCode = opts.purgeSession === true ? true : opts.keepSession !== true
This ensures consistent behavior across all kill paths.
Adds --keep-session flag to both commands.
Adds regression tests for provider-agnostic behavior verified.
Addresses Bugbot finding: orchestrator start cleanup inconsistent
with new purge default (2bc2535f-b2d1-4f64-96d6-150f97ed8564)
Wire the Dashboard component to the existing /api/events SSE endpoint
so session status, activity, and lastActivityAt update in real-time
without requiring a full page refresh. SSE snapshots are lightweight
patches merged into the full server-rendered session objects.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>