Fixes#1048. ao start used to allocate a fresh `{prefix}-orchestrator-N`
on every invocation instead of reattaching to the previous session, and
the dashboard's orchestrator link pointed at a different id than the
CLI just printed.
Changes:
runStartup (packages/cli/src/commands/start.ts):
- On startup, list existing orchestrators for the project, partition
them into live (runtime still running) and restorable (terminal but
sm.restore()-able) buckets, pick the most-recently-active from the
chosen bucket, and reuse/restore that id instead of spawning a new
one. Only spawn fresh when both buckets are empty.
- Live is preferred UNCONDITIONALLY over restorable — a newer killed
record can never beat an older-but-running one. Without this, a
cross-bucket sort could resurrect a killed record via sm.restore()
while the live orchestrator kept running, leaving two alive.
- Restored sessions get an explicit "(restored)" marker in the CLI
summary so the resurfaced id isn't a surprise.
- The phantom `${prefix}-orchestrator` id constant is removed from
every URL print, browser-open target, and summary line. Everything
now uses the real selected id.
registerStop (same file):
- ao stop now resolves the real orchestrator via sm.list(projectId)
+ isOrchestratorSession filter + most-recently-active sort, then
calls sm.kill on that id. The old phantom `${prefix}-orchestrator`
target never matched a real numbered record, so ao stop was a
silent no-op and the orchestrator kept running on disk between
start cycles. sm.list-failure warning no longer duplicates with the
generic "no orchestrator found" message.
isOrchestratorSession (packages/core/src/types.ts):
- Tightened: legacy bare-id records (`{projectId}-orchestrator` with
no role metadata) are no longer recognized as orchestrators by the
public predicate. This was the source of the dashboard/CLI id
divergence — stale bare records with a different prefix than the
numbered form were leaking into the dashboard's orchestrator list.
session-manager repair (packages/core/src/session-manager.ts):
- Split `isOrchestratorSessionRecord` (permissive, used by cleanup
protection) from a new `isRepairableOrchestratorRecord` (stricter,
used only by repairSingleSessionMetadataOnRead and
repairSessionMetadataOnRead).
- The strict repair predicate accepts role-stamped records, the bare
`{sessionPrefix}-orchestrator` correct-prefix legacy shape, and the
numbered `{sessionPrefix}-orchestrator-N` worktree shape. It
rejects foreign bare names like `{projectId}-orchestrator`, so
those records never get `role: orchestrator` backfilled on read
and therefore can no longer pass `isOrchestratorSession()` in real
`sm.list()` output via the role-metadata branch.
Tests added (~12):
- runStartup: live reuse, restore-on-killed, ignore-stale-bare
legacy records, live-beats-restorable regression, multi-live
reuse, URL fallback when --no-orchestrator.
- ao stop: kills the actual numbered id (not the phantom), handles
multiple orchestrators, tolerates sm.list throwing.
- isOrchestratorSession: rejects stale bare ids without role
metadata; accepts bare ids with role metadata stamped.
- listDashboardOrchestrators: stale bare excluded, numbered live
included, role-stamped legacy included.
- session-manager repair: does not backfill role onto foreign bare-id
records (issue #1048 regression guard).
Unblocks: review comments from cursor[bot] (dead else-if branch,
double messaging, redundant isTerminalSession check) and illegalcall
(cross-bucket sort, repair-backfill bypass of predicate tightening) —
all addressed in-place with the multi-orchestrator model preserved.
Verified: core 606/606, cli 450/450, typecheck clean across core/cli/web.
* feat(web): fix terminal height to fill viewport, prevent outside scrolling
* feat(web): add touch scroll, font size control, and accurate fit to DirectTerminal
* feat(web): add mobile sidebar overlay, hamburger toggle, and reconnect indicator
* feat(web): add hamburger and reconnect pill to topbar
* fix(web): use xterm instead of @xterm/xterm for terminal-touch-scroll import
Upstream uses xterm@5.3.0 (not @xterm/xterm), fix the type import accordingly.
* ci: make pnpm audit strict step non-blocking
npm's legacy audit endpoint (/npm/v1/security/audits) is returning 410 Gone
as it's being retired. Add continue-on-error until pnpm ships support for
the new bulk advisory endpoint.
* fix(web): replace TerminalLike with minimal interface compatible with xterm@5.3.0
xterm@5.3.0 does not have 'input' or 'attachCustomWheelEventHandler' methods
(those are @xterm/xterm v6 APIs). Define a minimal structural interface
matching only the members actually used in the file.
* fix(web): replace mobile back button with hamburger sidebar toggle on session page
On mobile, the session detail page now shows a hamburger button instead of a back arrow, which toggles the sidebar via SidebarContext. This provides better navigation consistency with the main dashboard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): filter sidebar sessions strictly by projectId to prevent prefix collision
Sessions are now filtered to only show those whose projectId matches a configured project. This prevents sessions from different AO projects (e.g., 'ao-' and 'unl-' prefixes) from being incorrectly merged under the same project entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): add ResizeObserver and deferred fit to fix blank terminal on mount
The terminal now uses a 100ms deferred fit timeout to ensure the container has been sized before measuring. Additionally, a ResizeObserver monitors the terminal container and calls fit() whenever its size changes, ensuring the terminal refits when flex layouts settle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): debounce session refresh to prevent aborting in-flight fetch calls
The useSessionEvents hook now tracks when the last fetch was started and skips scheduling a new refresh if one was started less than 500ms ago. This prevents aggressive AbortController usage that was canceling in-flight requests unnecessarily. Also avoid rescheduling if a debounce timer is already pending.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(web): update SessionDetail mobile test for new sidebar toggle button
Update the test to expect the new "Toggle sidebar" aria-label instead of the previous "Back to dashboard" label on the mobile floating header.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): active session amber color, compact session meta row, tighter font controls
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): address review feedback — fontSize rerender, dead code, test cleanup
- DirectTerminal: remove fontSize from main init useEffect deps. A dedicated
effect below mutates terminal.options.fontSize in place; keeping it in the
init deps tore down and recreated the terminal (and WebSocket) on every
stepper click, losing scrollback and flashing content.
- Remove dead ReconnectingPill component — it rendered null with a placeholder
comment. Delete the file, import, and render site rather than leaving a shell.
- ProjectSidebar: make the done-filter consistent by using getAttentionLevel
in the sessionsByProject collector. Previously the collector filtered by the
narrow `status === "done"` while the render sites filtered by the broader
`getAttentionLevel(s) === "done"`, so the project badge count could include
merged/killed/terminated sessions that the rendered list hid.
- Drop the two gutted tests that tested JS primitives rather than the component
(layout-height.test.tsx, DirectTerminal.test.tsx). They provided false
coverage. Update ProjectSidebar.test.tsx for the new anchor-based session
rows (click behavior now queries role="link", and clicking the project
toggle expands rather than navigates — the separate dashboard button handles
navigation).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): make SessionDetail topbar tests pass after redesign
Reconcile the rebased-upstream tests with our redesigned SessionDetail:
Component:
- Show session.id in the topbar next to the headline. The ID is a stable
identifier (useful for copy/paste) while the headline changes with PR/
issue titles.
- Gate the topbar Orchestrator link on `!isOrchestrator` so we don't link
the orchestrator page to itself.
- Convert the PR popover toggle from a <button> to an <a href={pr.url}>.
Plain click still toggles the popover; ctrl/cmd-click opens the PR on
GitHub in a new tab. aria-label="PR #N" keeps the accessible name stable
regardless of the rendered chevron.
Tests:
- Mobile tests scope orchestrator link queries to the topbar via
within(getByRole("banner")) because MobileBottomNav also has an
"Orchestrator" entry.
- Desktop test opens the PR popover before asserting the PR detail
contents (blocker chips, file count, unresolved comments) — they now
live inside the popover rather than inline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(web): restore behavioral tests + remove dead mobileOpen prop
Adds missing behavioral coverage the reviewer flagged on Dashboard.mobile.test.tsx:
- Termination confirmation flow: clicking the kill button once enters a
confirming state (aria-label becomes "Confirm terminate session") without
firing the kill request; a second click POSTs to /api/sessions/:id/kill.
- CI check chip rendering: SessionCard renders passing CI check names as chips
when the session has an enriched PR.
Removes the dead `mobileOpen` prop from ProjectSidebar:
- It was declared but immediately aliased as `_mobileOpen` and never used —
the actual mobile overlay state is driven by the `sidebar-wrapper--mobile-open`
class on the parent wrapper div in Dashboard/SessionDetail.
- PullRequestsPage was passing it too but never wired the wrapper class, so
its hamburger buttons were silently no-ops. Wrapped its sidebar in the same
sidebar-wrapper + backdrop pattern so the buckled hamburgers now actually
open the sidebar overlay on mobile.
Not in scope for this round (per reviewer): attention bucket filter /
MobileBottomNav on Dashboard — Dashboard doesn't implement those features,
so there's no behavior to assert. Those would need a feature add, not a test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): listCached stale-data bug + drop inline styles in DirectTerminal
Addresses the reviewer's critical and minor feedback on PR #1278.
Critical — listCached was stale across mutation paths
listCached() had a 35s TTL, invalidated only by spawn() and kill().
Every other metadata-writing path (claimPR, restore, send, remap,
cleanup, lifecycle-manager's direct updateMetadata calls for PR
detection and state transitions) left stale data visible to
/api/sessions for up to 35s.
Fix:
- Wrap updateMetadata / writeMetadata / deleteMetadata inside
createSessionManager() so every in-file mutation auto-invalidates
the cache. The raw imports are aliased as _rawXxx and only used
by the wrappers. No call site needs to remember to invalidate.
- Expose invalidateCache() on the SessionManager interface for
callers that write metadata outside this module.
- lifecycle-manager.ts: call sessionManager.invalidateCache() after
its two direct updateMetadata sites (PR detection, state update)
so polling-driven mutations are visible on the next poll.
- Move the _cache / invalidateCache declarations to the top of the
closure so the mutation wrappers can reference them.
- Update createMockSessionManager + api-routes.test.ts mocks to
satisfy the expanded interface.
- New regression test: external mutation + sm.invalidateCache() →
next listCached() re-reads disk.
C-02 — inline styles on the terminal-container div
DirectTerminal.tsx was using style={{ overflow, display,
flexDirection, flex, minHeight }} — all static values moved to
Tailwind utilities: "w-full p-1.5 flex flex-col flex-1 min-h-0
overflow-hidden".
Not addressed in this commit (noted as out-of-scope refactor):
C-04 400-line cap on DirectTerminal.tsx. Splitting fontSizeControls
and touch-scroll wiring into sub-components is worthwhile but
mechanical — separate PR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The shared gh wrapper now extracts PR URLs with a regex instead of embedding a literal github URL, so the old codex assertion was stale and broke CI on PR #1300.
Record that the remaining lifecycle-state review regressions were fixed in this follow-up branch so the changeset summary matches the actual scope of the patch.
Fix legacy merged-session rehydration so merged PRs stay non-restorable after v2 synthesis. Also validate canonical lifecycle payloads before normalizing them so malformed statePayload data falls back to synthesized metadata instead of corrupting lifecycle state.
Resolved conflicts in:
- packages/web/src/lib/types.ts (merged canonical type imports with DashboardAttentionZoneMode)
- packages/web/src/lib/dashboard-page-data.ts (kept upstream imports)
- packages/web/src/components/Dashboard.tsx (kept upstream mobileKanbanOrder)
Updated getAttentionLevel to support mode parameter from upstream
while preserving lifecycle-aware helper functions.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The isDetectingEscalated check now considers both attempt limit
AND time limit, ensuring detectingEscalatedAt is set regardless
of which escalation path triggers the stuck state.
Addresses suppressed review comment about time-based escalation.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Allow spawning sessions in shouldAuditSession for acknowledge timeout
- Remove dead agent_blocked trigger type (never returned)
- Clear audit metadata when no trigger fires
- Protect completedAt/terminatedAt from being overwritten
- Ensure lifecycle patch takes precedence over additionalMetadata
- Add changeset entry for new exports
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Document the Karpathy-style working principles in CLAUDE.md and add condensed references in contributor-facing docs so humans and agents follow the same guidance.
Issue #137: Centralize lifecycle transitions
- Create lifecycle-transition.ts with applyLifecycleDecision function
- Provide consistent mutation boundary for lifecycle state changes
- Export buildTransitionMetadataPatch and createStateTransitionDecision helpers
Issue #138: Make detecting a real bounded transition state
- Add time-based escalation (5 min hard cap) in addition to attempt-based (3 attempts)
- Add evidence hashing to prevent counter reset on unchanged evidence
- Track detectingStartedAt and detectingEvidenceHash in metadata
- Escalate to stuck when either limit is exceeded
Issue #140: Report Watcher for agent reports
- Create report-watcher.ts with audit functions
- Add checkAcknowledgeTimeout (10 min default)
- Add checkStaleReport (30 min default)
- Add checkBlockedAgent for needs_input state
- Integrate auditAndReactToReports into lifecycle polling loop
- Surface triggers as reactions (report-no-acknowledge, report-stale, etc.)
Tests:
- 19 tests for lifecycle-status-decisions (detecting bounds, hashing)
- 13 tests for lifecycle-transition (decision application, metadata)
- 19 tests for report-watcher (audit triggers, priorities)
Closes#137, #138, #140
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Move extracted determineStatus decision helpers into an internal module so the refactor does not expand the public lifecycle-manager API surface.
Also share merged and closed PR terminal-state mapping between cached and live SCM decision paths to prevent drift while addressing PR review feedback.
Extract probe and PR decision helpers out of determineStatus so lifecycle state selection is easier to review without mixing it with mutation and metadata commits.
Add focused helper coverage and lock merged PR metadata truth to avoid regressions toward a no-PR state while refactoring the lifecycle path.
* feat(web): collapse attention zones to 4 with feature flag for 5 (#1201)
Simplify the dashboard to a 4-zone kanban by default (WORKING,
PENDING, ACTION, READY), merging the former REVIEW + RESPOND
columns into a single ACTION zone that just asks "does the human
need to do something?". The card-level badges still surface the
underlying granular state (ci_failed, needs_input, changes_requested),
so nothing is lost — it just moves off the column header.
Add a `dashboard.attentionZones` config (defaults to "simple") that
opts back into the original 5-zone layout for power users. The
function-level `getAttentionLevel(session, mode)` still defaults to
"detailed" so card-level call sites (SessionCard, BottomSheet,
ProjectSidebar, etc.) keep their granular behavior untouched. Only
the Dashboard kanban and SSE server routes read the config and pass
the mode.
Closes#1201
* fix(web): address PR review feedback on attention zones (#1201)
- Drop .strict() from DashboardConfigSchema (Cursor Bugbot):
matches other schemas in config.ts so typos in the dashboard
block gracefully default instead of crashing the whole loader.
- DynamicFavicon: keep `action` at yellow, not red (Codex P2).
In simple mode, `action` collapses respond + review, which
means it necessarily catches routine review work (ci_failed,
changes_requested) that was previously yellow. Escalating to
red would make every typical PR scream critical. Only the
detailed-mode `respond` bucket still triggers red.
- useSessionEvents default → "detailed" (Codex P3). The hook
default now matches getAttentionLevel's default so callers
that seed with `getAttentionLevel(s)` (no mode) — notably
PullRequestsPage — stay consistent with their seed after
the first live SSE/refresh snapshot. Dashboard explicitly
passes the config's attentionZones to opt into simple mode.
- Expand mobile action chip (Codex P3). When a session
collapses to `action`, derive the most specific underlying
cause (needs input, stuck, errored, ci failed, changes,
waiting, conflicts) instead of falling through to the
generic "action" label, so mobile users keep the reason to
intervene.
- DynamicFavicon tests: add cases for `action` staying yellow
and `respond` still escalating when both are present.
* fix(web): thread attentionZones config through PullRequestsPage (#1201)
Cursor Bugbot caught a deeper version of the same inconsistency
my previous fix didn't fully resolve: the server SSE route uses
`config.dashboard?.attentionZones ?? "simple"`, but PullRequestsPage
was seeding `initialAttentionLevels` and calling useSessionEvents
without passing any mode (falling back to the hook's "detailed"
default). Result: snapshots from the server arrived as "action"/
"merge", then scheduleRefresh recomputed them client-side as
"respond"/"review", and the favicon oscillated between yellow
and red on every refresh cycle.
Fix: plumb pageData.attentionZones through prs/page.tsx into
PullRequestsPage, use it in both the seed and useSessionEvents.
Now the seed, the server SSE, and the client refresh all use the
same mode — stable, no flicker.
* fix(web): accurate action labels for crashed agents + yellow tone (#1201)
Two Cursor Bugbot findings on the action-zone collapse work:
1. [Medium] Mobile chip mislabeled crashed agents as "needs input".
When an agent's activity is `exited` and the session is in the
action bucket, the agent has crashed — sending it a message
won't help, the user needs to restart. Split the label: exited
→ "crashed", blocked → "blocked".
2. [Low] ProjectMetric for Action used tone="error" (red), which
contradicts the favicon fix's rationale. The action bucket
catches routine review work (ci_failed, changes_requested)
that was previously orange/yellow severity; screaming red in
the project overview grid would have the same cry-wolf problem
as the favicon did. Use tone="orange" (the less severe of the
two merged buckets) to match.
* refactor(web): make attentionZones required in useSessionEvents (#1201)
Cursor Bugbot caught that the hook default of "detailed" mismatches
the server SSE route default of "simple" — a latent footgun for any
future caller that forgets to pass the mode explicitly. The exact
oscillation bug I already fixed once for PullRequestsPage would
silently come back on the next page that uses this hook.
Real fix: refactor the hook to take an options object and make
`attentionZones` required with no default. TypeScript now enforces
that every caller explicitly passes the mode the server is using.
You literally cannot call useSessionEvents without supplying it.
Also cleaner: the hook had 6 positional parameters, which is past
the point where an options object helps readability. Dashboard and
PullRequestsPage call sites now read as self-documenting.
* fix(web): address human code review on attention zones (#1201)
Review from i-trytoohard on commit 8d27db79:
1. [Medium] `ZoneCounts` in sessions/[id]/page.tsx had an `action`
field that was always 0 — the page always computes in detailed
mode (getAttentionLevel with no mode = detailed), and the
consumer (`OrchestratorZones` in SessionDetail) never reads
an `action` field either. Removed the dead field, added a
`continue` guard for the never-reached "action" case so
TypeScript narrows the index correctly, and documented the
intent in a header comment.
2. [Medium] The simple-mode action chip had 10+ sub-conditions
with no tests. Extracted the label logic into a pure
`getActionChipLabel(session)` helper and added 16 unit tests
covering every branch: status-based signals (needs_input,
stuck, errored, ci_failed, changes_requested), activity-based
(waiting_input, exited→crashed, blocked), PR-based (failing
CI, changes_requested, conflicts), precedence between signal
classes, and the generic fallback.
3. [Low] `LEVEL_LABELS.action` in ProjectSidebar is dead code
today (sidebar uses detailed mode). Added a comment explaining
why the entry still exists (TypeScript exhaustiveness on
`Record<AttentionLevel, string>` + forward-compat).
* fix(web): address inline review on attention zones (#1201)
- Reorder getActionChipLabel to mirror getDetailedAttentionLevel: respond-class
activity (waiting_input/exited/blocked) now outranks review-class status
(ci_failed/changes_requested). A crashed agent with changes_requested no
longer reads as "changes" and hides the crash.
- Paint data-level="action" dot with --color-accent-orange to match the
favicon's yellow-severity treatment of the collapsed bucket.
- Tighten attentionLevel on the mux boundary (mux-protocol, mux-websocket,
useSessionEvents) from string to AttentionLevel so invalid values like "none"
no longer launder through into DynamicFavicon's count.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>