diff --git a/PLANS.md b/PLANS.md
index d4102812..f947598c 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -1113,7 +1113,7 @@ invalidation; the 2.27 MB bounded view is built on demand, shared per revision,
and served with an exact ETag. On retained local state, a matching conditional
read returned 304 with zero bytes in 0.000789 seconds, and the observed idle
control-plane sample fell from the old 52–55% projection-fanout workload to
-0.2%. Studio retains its last good view and reports Live data, Updating, or
+0.2%. Studio retains its last good view and reports SSE connected, Updating, or
Reconnecting instead of silently replacing megabytes of state.
The product bottleneck has moved from discovery volume to opportunity delivery.
diff --git a/README.md b/README.md
index d2ecc597..61802fac 100644
--- a/README.md
+++ b/README.md
@@ -116,16 +116,20 @@ smoke commands, read [Operations](docs/OPERATIONS.md).
## First use
-1. Open Studio and check **Readiness** for the control plane, catalog, storage,
- Agent runtime, and credential posture.
+1. Open Studio. The default **Discover** page reports **System ready** source
+ health and a **Refresh catalogs** first step. The same refresh also lives on
+ **System overview**.
2. Refresh anonymous catalogs. A refresh creates a new immutable corpus; it
does not spend model budget or grant trading authority.
-3. Open the ontology / discovery workspace and inspect the standing campaigns.
- Run a bounded campaign or issue against the retained corpus.
-4. Read the effect timeline, exact listing references, counterexamples, and
+3. If discovery is blocked (`credential unavailable`), open **Agent operations**
+ to inspect the existing Codex session. Stay on Discover to keep refreshing
+ catalogs without DeepSeek or Codex; **Explore next** is not the next click.
+4. After catalogs are in view, start a heuristic scan or inspect standing
+ campaigns against the retained corpus.
+5. Read the effect timeline, exact listing references, counterexamples, and
token usage. An empty or falsified run is retained research evidence.
-5. Move only a grounded multi-listing hypothesis into independent review.
-6. Treat economic hints as routing signals until fresh books, fees, depth, and
+6. Move only a grounded multi-listing hypothesis into independent review.
+7. Treat economic hints as routing signals until fresh books, fees, depth, and
the exact verifier all agree.
The longer operator walkthrough is in [Studio](docs/STUDIO.md).
diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx
index e5271375..d6394e52 100644
--- a/apps/studio/src/App.tsx
+++ b/apps/studio/src/App.tsx
@@ -65,12 +65,22 @@ import {
} from "@/data/studio-projection";
import { buildOpportunityFrontier } from "@/data/opportunity-frontier";
import { useDiscoveryExecutionCapability } from "@/data/discovery-execution";
+import {
+ discoverFirstStep,
+ discoverSpendAction,
+ findingsPrimaryAction,
+ inspirationEmptyState,
+ isCredentialBlockedDispatch,
+} from "@/data/first-operator-next-step";
import {
useStandingRouteWorkspace,
type StandingRouteState,
type StandingRouteUsage,
} from "@/data/standing-routes";
+import { describeSidebarCatalogStatus } from "@/lib/sidebar-catalog-status";
import { cn } from "@/lib/utils";
+import { presentVenueCapabilityChips } from "@/lib/venue-capability-chip";
+import { reviewQueueNeedsKeyPath } from "@/lib/review-queue-needs-key";
import {
parseWorkspaceRoute,
serializeWorkspaceRoute,
@@ -3891,18 +3901,37 @@ async function requestCandidateWatchRefresh(): Promise<"READY" | "DEGRADED"> {
function SidebarStatus() {
const studioProjection = useStudioProjection();
- const observation = studioProjection.ai.catalogObservation;
+ const status = describeSidebarCatalogStatus(
+ studioProjection.ai.catalogObservation,
+ );
return (
-
-
-
-
System ready
-
- {observation.healthySourceCount}/{observation.sourceCount} sources ·{" "}
- {observation.listingCount} markets
-
+
+
+
+
+ {status.heading}
+ {status.countLabel}
+
+
+
+ {status.unhealthySources.length === 0 ? (
+
All catalog sources are current.
+ ) : (
+
+ {status.unhealthySources.map((source) => (
+
+ {source.venueId}
+ {source.statusLabel}
+ {source.diagnostic !== null && (
+ {source.diagnostic}
+ )}
+
+ ))}
+
+ )}
+
{status.refreshHint}
-
+
);
}
@@ -4053,7 +4082,7 @@ function Topbar({
? `${Math.floor(staleAge / 60_000)}m old`
: `${Math.floor(staleAge / 3_600_000)}h old`;
const syncLabel = projectionSync.status === "LIVE"
- ? "Live data"
+ ? "SSE connected"
: projectionSync.status === "STALE_REVALIDATING"
? `Last known · ${staleAgeLabel} · revalidating`
: projectionSync.status === "REFRESHING"
@@ -6030,7 +6059,7 @@ function Overview({
{studioProjection.identity.stateHash.slice(0, 22)}…
- Live data
+ Observed catalog
{studioProjection.identity.mode} · {studioProjection.identity.view}
@@ -6038,7 +6067,7 @@ function Overview({
@@ -6945,8 +6974,13 @@ function RealCandidatePreflightView() {
);
}
-function MarketArchaeologistView() {
+function MarketArchaeologistView({
+ onNavigate,
+}: {
+ onNavigate: (view: View) => void;
+}) {
const studioProjection = useStudioProjection();
+ const catalogObservation = studioProjection.ai.catalogObservation;
const corpus =
studioProjection.ai.marketCorpus ?? EMPTY_MARKET_CORPUS;
const catalogRefreshScheduler =
@@ -7050,6 +7084,9 @@ function MarketArchaeologistView() {
const [deepRetryLeaseId, setDeepRetryLeaseId] = useState(null);
const [issueAction, setIssueAction] = useState(null);
const [issueDiagnostic, setIssueDiagnostic] = useState(null);
+ const [refreshStatus, setRefreshStatus] = useState<
+ "IDLE" | "RUNNING" | "READY" | "DEGRADED" | "FAILED"
+ >("IDLE");
const [newIssueTitle, setNewIssueTitle] = useState("");
const [newIssueQuestion, setNewIssueQuestion] = useState("");
const [newIssueLens, setNewIssueLens] = useState("EQUIVALENCE");
@@ -7058,6 +7095,17 @@ function MarketArchaeologistView() {
const discoveryCapability = discoveryExecution.data?.capability;
const discoveryRuntime = discoveryExecution.data?.runtime;
const discoveryModel = discoveryExecution.data?.model;
+ const discoveryBlocked = isCredentialBlockedDispatch(
+ discoveryCapability?.dispatchEligibility,
+ discoveryCapability?.diagnostic ?? discoveryExecution.diagnostic,
+ );
+ const firstStep = discoverFirstStep({
+ healthySourceCount: catalogObservation.healthySourceCount,
+ sourceCount: catalogObservation.sourceCount,
+ listingCount: catalogObservation.listingCount,
+ });
+ const spendAction = discoverSpendAction(discoveryBlocked);
+ const emptyInspiration = inspirationEmptyState(discoveryBlocked);
const currentLensRecords = scheduler.records.filter(
(record) => record.lease.snapshotIdentity === corpus.snapshotIdentity,
);
@@ -7093,6 +7141,15 @@ function MarketArchaeologistView() {
}
}
+ async function refreshCatalog(): Promise {
+ setRefreshStatus("RUNNING");
+ try {
+ setRefreshStatus(await requestCatalogRefresh());
+ } catch {
+ setRefreshStatus("FAILED");
+ }
+ }
+
async function retryDeep(leaseId: string): Promise {
setDeepRetryLeaseId(leaseId);
setLeaseDiagnostic(null);
@@ -7207,23 +7264,30 @@ function MarketArchaeologistView() {
{discoveryExecution.preflightBusy ? : }
{discoveryCapability?.observation == null ? "Preflight" : "Recheck"}
- void runLease()}
- >
- {leaseStatus === "RUNNING" ? (
-
- ) : (
-
- )}
- {leaseStatus === "RUNNING" ? "Exploring…" : "Start heuristic scan"}
-
+ {spendAction.kind === "OPEN_AGENT_OPERATIONS" ? (
+ onNavigate("agents")}>
+
+ {spendAction.label}
+
+ ) : (
+ void runLease()}
+ >
+ {leaseStatus === "RUNNING" ? (
+
+ ) : (
+
+ )}
+ {leaseStatus === "RUNNING" ? "Exploring…" : spendAction.label}
+
+ )}
@@ -7234,9 +7298,32 @@ function MarketArchaeologistView() {
)}
+
+
+
{firstStep.title}
+
{firstStep.sourceHealthLabel}
+
{firstStep.body}
+
+
+ void refreshCatalog()}
+ >
+
+ {refreshStatus === "RUNNING"
+ ? "Refreshing…"
+ : refreshStatus === "FAILED"
+ ? "Retry refresh"
+ : refreshStatus === "READY" || refreshStatus === "DEGRADED"
+ ? "Catalogs refreshed"
+ : firstStep.primaryLabel}
+
+
+
+
@@ -7344,8 +7431,8 @@ function MarketArchaeologistView() {
-
No useful detours yet
-
When a heuristic scan finds a grounded relation outside its assignment, it appears here instead of being forced into a claim.
+
{emptyInspiration.title}
+
{emptyInspiration.body}
) : (
@@ -8789,6 +8876,10 @@ function OpportunityLifecycleView({
1,
...recentUsageHours.map((bucket) => Number(bucket.invocationCount)),
);
+ const needsKeyPath = reviewQueueNeedsKeyPath({
+ reviewerConfigured: semanticReview.configured,
+ estimatorsConfigured: probabilityEstimation.configured,
+ });
return (
@@ -8817,6 +8908,17 @@ function OpportunityLifecycleView({
+ {needsKeyPath !== null && (
+
+
+
+ The review lane is idle until the existing{" "}
+ Agent operations
+ {" "}session exists.
+
+
+ )}
+
{focusedProposalIds.length > 0 && (
@@ -10871,7 +10973,7 @@ function OpportunityRadarView() {
? "Sources refreshed"
: refreshStatus === "FAILED"
? "Retry refresh"
- : "Refresh live radar"}
+ : "Refresh catalogs"}
@@ -10889,7 +10991,7 @@ function OpportunityRadarView() {
candidate.indicativeEconomics.status === "POSITIVE_GROSS_HINT").length} positive gross hints`}
+ detail={`${radar.candidates.filter((candidate) => candidate.indicativeEconomics.status === "POSITIVE_GROSS_HINT").length} catalog-price overlap hints · not executable`}
/>
Exact two-listing context · proposal only · no auto spend
+ · a click spends scout or pi budget
0}
onClick={() => void triage(candidate)}
>
@@ -11034,17 +11139,18 @@ function OpportunityRadarView() {
)}
{running
- ? "Scouts triaging…"
+ ? "Spending scout budget…"
: localState === "DONE"
? "Triage complete"
: localState === "RESTORED" || retained
? "Restore scout result"
: localState === "FAILED"
- ? "Retry scout triage"
- : "Triage with fast scouts"}
+ ? "Retry scout spend"
+ : "Spend scout budget to triage"}
)}
{investigating
- ? "pi investigating…"
+ ? "Spending deep-pi budget…"
: investigationState === "DONE"
? "pi complete"
: investigationState === "RESTORED" ||
@@ -11067,8 +11173,8 @@ function OpportunityRadarView() {
? "Restore pi report"
: investigationState === "FAILED" ||
investigationRecord?.status === "FAILED"
- ? "Retry deep pi"
- : "Run deep pi"}
+ ? "Retry deep-pi spend"
+ : "Spend deep-pi model budget"}
@@ -11388,8 +11494,10 @@ function StandingRouteMemory({ revision }: { revision: string }) {
function ScoutInboxView({
onOpenReview,
+ onNavigate,
}: {
onOpenReview: (proposalIds: readonly string[]) => void;
+ onNavigate: (view: View) => void;
}) {
const studioProjection = useStudioProjection();
const scheduler = studioProjection.ai.searchLeaseScheduler ?? EMPTY_SEARCH_LEASE_SCHEDULER;
@@ -11430,6 +11538,10 @@ function ScoutInboxView({
const discoveryCapability = discoveryExecution.data?.capability;
const discoveryRuntime = discoveryExecution.data?.runtime;
const discoveryModel = discoveryExecution.data?.model;
+ const findingsAction = findingsPrimaryAction({
+ dispatchEligibility: discoveryCapability?.dispatchEligibility,
+ diagnostic: discoveryCapability?.diagnostic ?? discoveryExecution.diagnostic,
+ });
const liveContextEligible =
catalogMode === "VERIFIED_FIXTURES" ||
selectedVenueIds.every(
@@ -11545,34 +11657,44 @@ function ScoutInboxView({
{discoveryExecution.preflightBusy ? : }
{discoveryCapability?.observation == null ? "Preflight" : "Recheck"}
- void exploreNext()}
- >
- {explorationStatus === "RUNNING" ? (
-
- ) : (
-
- )}
- {explorationStatus === "RUNNING"
- ? "Exploring…"
- : explorationStatus === "RESTORED"
- ? "Latest scan restored"
- : explorationStatus === "FAILED"
- ? "Retry exploration"
- : "Explore next"}
-
+ {findingsAction.kind === "OPEN_AGENT_OPERATIONS" ? (
+ onNavigate("agents")}>
+
+ {findingsAction.label}
+
+ ) : (
+ void exploreNext()}
+ >
+ {explorationStatus === "RUNNING" ? (
+
+ ) : (
+
+ )}
+ {explorationStatus === "RUNNING"
+ ? "Exploring…"
+ : explorationStatus === "RESTORED"
+ ? "Latest scan restored"
+ : explorationStatus === "FAILED"
+ ? "Retry exploration"
+ : findingsAction.label}
+
+ )}
{(discoveryExecution.diagnostic !== null || discoveryCapability?.dispatchEligibility === "BLOCKED") && (
- {discoveryExecution.diagnostic ?? `Discovery is blocked before model spend: ${discoveryCapability?.diagnostic ?? "run a capability preflight"}`}
+
+
{discoveryExecution.diagnostic ?? `Discovery is blocked before model spend: ${discoveryCapability?.diagnostic ?? "run a capability preflight"}`}
+ {findingsAction.helper !== "" &&
{findingsAction.helper}
}
+
)}
@@ -12800,8 +12922,16 @@ function VenueMatrix() {
- {venue.capabilities.map((capability) => (
- {capability}
+ {presentVenueCapabilityChips(
+ venue.capabilities,
+ venue.liveExecutionEnabled,
+ ).map((chip) => (
+
+ {chip.label}
+
))}
@@ -14073,7 +14203,9 @@ function StudioShell({ projectionSync }: { projectionSync: ProjectionSyncState }
{view === "overview" && }
{view === "agents" && }
- {view === "archaeologist" && }
+ {view === "archaeologist" && (
+ navigate(nextView)} />
+ )}
{view === "lifecycle" && (
navigate("lifecycle", proposalIds)}
+ onNavigate={(nextView) => navigate(nextView)}
/>
)}
{view === "budgets" && (
diff --git a/apps/studio/src/data/first-operator-next-step.test.ts b/apps/studio/src/data/first-operator-next-step.test.ts
new file mode 100644
index 00000000..c6db5230
--- /dev/null
+++ b/apps/studio/src/data/first-operator-next-step.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ catalogHealthLabel,
+ discoverFirstStep,
+ discoverSpendAction,
+ findingsPrimaryAction,
+ inspirationEmptyState,
+ isCredentialBlockedDispatch,
+} from "./first-operator-next-step";
+
+const emptyCatalog = Object.freeze({
+ healthySourceCount: 0,
+ sourceCount: 7,
+ listingCount: 0,
+});
+
+const populatedCatalog = Object.freeze({
+ healthySourceCount: 6,
+ sourceCount: 7,
+ listingCount: 600,
+});
+
+describe("first-operator next step", () => {
+ it("uses the sidebar System ready words for source health", () => {
+ expect(catalogHealthLabel(populatedCatalog)).toBe(
+ "System ready · 6/7 sources · 600 markets",
+ );
+ });
+
+ it("makes Refresh catalogs the Discover first step on an empty desk", () => {
+ const step = discoverFirstStep(emptyCatalog);
+ expect(step.title).toBe("First step");
+ expect(step.primaryAction).toBe("REFRESH_CATALOGS");
+ expect(step.primaryLabel).toBe("Refresh catalogs");
+ expect(step.sourceHealthLabel).toContain("0/7 sources");
+ expect(step.body).toMatch(/Refresh anonymous catalogs/);
+ expect(step.body).not.toMatch(/Readiness/);
+ });
+
+ it("keeps Refresh catalogs visible after catalogs already exist", () => {
+ const step = discoverFirstStep(populatedCatalog);
+ expect(step.primaryLabel).toBe("Refresh catalogs");
+ expect(step.sourceHealthLabel).toBe(
+ "System ready · 6/7 sources · 600 markets",
+ );
+ expect(step.body).toMatch(/Refresh catalogs/);
+ expect(step.body).toMatch(/heuristic scan/);
+ });
+
+ it("tells an empty inspiration inbox what to do next", () => {
+ expect(inspirationEmptyState(false)).toEqual({
+ title: "No useful detours yet",
+ body: "Refresh catalogs on this page first, then start a heuristic scan. Cross-lens inspirations appear here after a scan finds a grounded relation outside its assignment.",
+ });
+ expect(inspirationEmptyState(true).body).toMatch(/Agent operations/);
+ expect(inspirationEmptyState(true).body).not.toMatch(/Readiness/);
+ });
+
+ it("treats BLOCKED and credential-unavailable diagnostics as blocked dispatch", () => {
+ expect(isCredentialBlockedDispatch("BLOCKED", "runtime unavailable")).toBe(true);
+ expect(isCredentialBlockedDispatch("ELIGIBLE", "credential unavailable")).toBe(false);
+ expect(isCredentialBlockedDispatch(undefined, "Discovery is blocked before model spend: credential unavailable")).toBe(true);
+ expect(isCredentialBlockedDispatch("ELIGIBLE", "ready")).toBe(false);
+ expect(isCredentialBlockedDispatch(undefined, null)).toBe(false);
+ });
+
+ it("replaces Explore next as the Findings primary when dispatch is blocked", () => {
+ const action = findingsPrimaryAction({
+ dispatchEligibility: "BLOCKED",
+ diagnostic: "credential unavailable",
+ });
+ expect(action.exploreNextPrimary).toBe(false);
+ expect(action.kind).toBe("OPEN_AGENT_OPERATIONS");
+ expect(action.label).toBe("Open Agent operations");
+ expect(action.helper).toMatch(/Codex OAuth session/);
+ expect(action.helper).toMatch(/Discover/);
+ expect(action.helper).not.toMatch(/Readiness/);
+ });
+
+ it("keeps Explore next as the Findings primary when dispatch is eligible", () => {
+ const action = findingsPrimaryAction({
+ dispatchEligibility: "ELIGIBLE",
+ diagnostic: "ready",
+ });
+ expect(action).toEqual({
+ kind: "EXPLORE_NEXT",
+ label: "Explore next",
+ helper: "",
+ exploreNextPrimary: true,
+ });
+ });
+
+ it("points Discover spend at Agent operations when credentials are blocked", () => {
+ expect(discoverSpendAction(true)).toEqual({
+ kind: "OPEN_AGENT_OPERATIONS",
+ label: "Open Agent operations",
+ });
+ expect(discoverSpendAction(false)).toEqual({
+ kind: "HEURISTIC_SCAN",
+ label: "Start heuristic scan",
+ });
+ });
+});
diff --git a/apps/studio/src/data/first-operator-next-step.ts b/apps/studio/src/data/first-operator-next-step.ts
new file mode 100644
index 00000000..2d22f382
--- /dev/null
+++ b/apps/studio/src/data/first-operator-next-step.ts
@@ -0,0 +1,91 @@
+export type DiscoveryDispatchEligibility = "ELIGIBLE" | "BLOCKED";
+
+export type CatalogHealth = Readonly<{
+ healthySourceCount: number;
+ sourceCount: number;
+ listingCount: number;
+}>;
+
+export function catalogHealthLabel(health: CatalogHealth): string {
+ return `System ready · ${health.healthySourceCount}/${health.sourceCount} sources · ${health.listingCount} markets`;
+}
+
+export function isCredentialBlockedDispatch(
+ eligibility: DiscoveryDispatchEligibility | undefined,
+ diagnostic: string | null | undefined,
+): boolean {
+ if (eligibility === "ELIGIBLE") return false;
+ if (eligibility === "BLOCKED") return true;
+ return (diagnostic ?? "").toLowerCase().includes("credential unavailable");
+}
+
+export function discoverFirstStep(health: CatalogHealth): Readonly<{
+ title: "First step";
+ sourceHealthLabel: string;
+ body: string;
+ primaryAction: "REFRESH_CATALOGS";
+ primaryLabel: "Refresh catalogs";
+}> {
+ return Object.freeze({
+ title: "First step",
+ sourceHealthLabel: catalogHealthLabel(health),
+ body: health.listingCount === 0
+ ? "Refresh anonymous catalogs to see which sources are healthy. This does not spend model budget or grant trading authority."
+ : "Anonymous catalogs are already in view. Refresh catalogs to take a new immutable corpus, then start a heuristic scan. Refresh does not spend model budget.",
+ primaryAction: "REFRESH_CATALOGS",
+ primaryLabel: "Refresh catalogs",
+ });
+}
+
+export function inspirationEmptyState(blocked: boolean): Readonly<{
+ title: "No useful detours yet";
+ body: string;
+}> {
+ return Object.freeze({
+ title: "No useful detours yet",
+ body: blocked
+ ? "Refresh catalogs here without a provider key, or open Agent operations to inspect the existing Codex session. Model spend stays blocked until that session is available."
+ : "Refresh catalogs on this page first, then start a heuristic scan. Cross-lens inspirations appear here after a scan finds a grounded relation outside its assignment.",
+ });
+}
+
+export function discoverSpendAction(blocked: boolean): Readonly<{
+ kind: "HEURISTIC_SCAN" | "OPEN_AGENT_OPERATIONS";
+ label: string;
+}> {
+ return blocked
+ ? Object.freeze({
+ kind: "OPEN_AGENT_OPERATIONS",
+ label: "Open Agent operations",
+ })
+ : Object.freeze({
+ kind: "HEURISTIC_SCAN",
+ label: "Start heuristic scan",
+ });
+}
+
+export function findingsPrimaryAction(input: Readonly<{
+ dispatchEligibility: DiscoveryDispatchEligibility | undefined;
+ diagnostic: string | null | undefined;
+}>): Readonly<{
+ kind: "EXPLORE_NEXT" | "OPEN_AGENT_OPERATIONS";
+ label: string;
+ helper: string;
+ exploreNextPrimary: boolean;
+}> {
+ if (isCredentialBlockedDispatch(input.dispatchEligibility, input.diagnostic)) {
+ return Object.freeze({
+ kind: "OPEN_AGENT_OPERATIONS",
+ label: "Open Agent operations",
+ helper:
+ "Attach the existing Codex OAuth session in Agent operations, or stay on Discover to refresh catalogs without DeepSeek or Codex.",
+ exploreNextPrimary: false,
+ });
+ }
+ return Object.freeze({
+ kind: "EXPLORE_NEXT",
+ label: "Explore next",
+ helper: "",
+ exploreNextPrimary: true,
+ });
+}
diff --git a/apps/studio/src/data/studio-projection.test.ts b/apps/studio/src/data/studio-projection.test.ts
index 0340c7f0..cae6f816 100644
--- a/apps/studio/src/data/studio-projection.test.ts
+++ b/apps/studio/src/data/studio-projection.test.ts
@@ -5,6 +5,7 @@ import {
RealCandidatePreflightDesk,
ReplayBookDesk,
} from "@pmh/control-plane";
+import { presentVenueCapabilityChips } from "../lib/venue-capability-chip.js";
import {
parseStartupReadiness,
parseProjectionInvalidation,
@@ -384,6 +385,35 @@ describe("Studio projection safety", () => {
expect(inertVenues.every((venue) => !venue.liveExecutionEnabled)).toBe(
true,
);
+ expect(
+ inertVenues.flatMap((venue) =>
+ presentVenueCapabilityChips(
+ venue.capabilities,
+ venue.liveExecutionEnabled,
+ ),
+ ).filter((chip) => chip.key === "ORDER_GATEWAY"),
+ ).toEqual([
+ {
+ key: "ORDER_GATEWAY",
+ label: "ORDER_GATEWAY · INERT",
+ inert: true,
+ },
+ {
+ key: "ORDER_GATEWAY",
+ label: "ORDER_GATEWAY · INERT",
+ inert: true,
+ },
+ ]);
+ expect(
+ studioProjection.venues
+ .flatMap((venue) =>
+ presentVenueCapabilityChips(
+ venue.capabilities,
+ venue.liveExecutionEnabled,
+ ),
+ )
+ .some((chip) => chip.key === "ORDER_GATEWAY" && !chip.inert),
+ ).toBe(false);
});
it("labels every displayed opportunity as exact fixture evidence", () => {
diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css
index 21281c5f..fc1e38aa 100644
--- a/apps/studio/src/index.css
+++ b/apps/studio/src/index.css
@@ -297,6 +297,25 @@ pre,
padding: 14px 8px 2px;
}
+details.sidebar-status {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+}
+
+.sidebar-status-summary {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 10px;
+ list-style: none;
+ cursor: pointer;
+}
+
+.sidebar-status-summary::-webkit-details-marker {
+ display: none;
+}
+
.sidebar-status-dot {
width: 8px;
height: 8px;
@@ -305,7 +324,17 @@ pre,
background: var(--primary);
}
-.sidebar-status > div {
+.sidebar-status.is-degraded .sidebar-status-dot {
+ background: var(--warning);
+}
+
+.sidebar-status.is-idle .sidebar-status-dot,
+.sidebar-status.is-refreshing .sidebar-status-dot {
+ background: var(--muted-foreground);
+}
+
+.sidebar-status > div,
+.sidebar-status-summary > div {
display: flex;
min-width: 0;
flex-direction: column;
@@ -314,7 +343,8 @@ pre,
.sidebar-status strong { font-size: 13px; }
-.sidebar-status span:last-child {
+.sidebar-status > div > span:last-child,
+.sidebar-status-summary span:last-child {
overflow: hidden;
color: var(--muted-foreground);
font-size: 12px;
@@ -322,6 +352,40 @@ pre,
white-space: nowrap;
}
+.sidebar-status-detail {
+ min-width: 0;
+ padding-left: 18px;
+}
+
+.sidebar-status-detail p,
+.sidebar-status-detail li {
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: var(--muted-foreground);
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.sidebar-status-detail ul {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin: 0 0 8px;
+ padding: 0;
+ list-style: none;
+}
+
+.sidebar-status-detail li {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.sidebar-status-detail li strong {
+ color: var(--foreground);
+ font-size: 12px;
+}
+
.venue-pulse {
border: 1px solid var(--border);
border-radius: 10px;
@@ -1311,6 +1375,12 @@ main {
padding: 4px 6px;
}
+.capability-chips span.is-inert {
+ border-color: rgba(255, 199, 142, 0.22);
+ background: rgba(255, 199, 142, 0.04);
+ color: #c99e75;
+}
+
.scout-heading {
padding-bottom: 32px;
}
@@ -2344,6 +2414,38 @@ main {
margin: 3px 0 0;
}
+.first-operator-step {
+ align-items: flex-start;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ display: flex;
+ gap: 18px;
+ justify-content: space-between;
+ margin: 0 0 18px;
+ padding: 16px;
+}
+
+.first-operator-step strong {
+ display: block;
+ font-size: 14px;
+ margin-top: 2px;
+}
+
+.first-operator-step p {
+ color: var(--muted-foreground);
+ font-size: 13px;
+ line-height: 1.5;
+ margin: 4px 0 0;
+}
+
+.first-operator-step-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
.inspiration-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -7600,6 +7702,11 @@ footer span:first-child {
justify-content: flex-start;
}
+ .first-operator-step {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
.archaeology-pipeline {
grid-template-columns: 1fr;
}
diff --git a/apps/studio/src/lib/review-queue-needs-key.test.ts b/apps/studio/src/lib/review-queue-needs-key.test.ts
new file mode 100644
index 00000000..7ff70549
--- /dev/null
+++ b/apps/studio/src/lib/review-queue-needs-key.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import { reviewQueueNeedsKeyPath } from "./review-queue-needs-key.js";
+import { serializeWorkspaceRoute } from "./workspace-route.js";
+
+describe("review queue NEEDS KEY path", () => {
+ it("returns null when reviewer and estimators are configured", () => {
+ expect(reviewQueueNeedsKeyPath({
+ reviewerConfigured: true,
+ estimatorsConfigured: true,
+ })).toBeNull();
+ });
+
+ it("points at Agent operations when the reviewer is not configured", () => {
+ expect(reviewQueueNeedsKeyPath({
+ reviewerConfigured: false,
+ estimatorsConfigured: true,
+ })).toBe(serializeWorkspaceRoute("agents"));
+ expect(reviewQueueNeedsKeyPath({
+ reviewerConfigured: false,
+ estimatorsConfigured: true,
+ })).toBe("?view=agents");
+ });
+
+ it("points at Agent operations when estimators are not configured", () => {
+ expect(reviewQueueNeedsKeyPath({
+ reviewerConfigured: true,
+ estimatorsConfigured: false,
+ })).toBe("?view=agents");
+ });
+
+ it("points at Agent operations when neither lane is configured", () => {
+ expect(reviewQueueNeedsKeyPath({
+ reviewerConfigured: false,
+ estimatorsConfigured: false,
+ })).toBe("?view=agents");
+ });
+});
diff --git a/apps/studio/src/lib/review-queue-needs-key.ts b/apps/studio/src/lib/review-queue-needs-key.ts
new file mode 100644
index 00000000..f0939b5f
--- /dev/null
+++ b/apps/studio/src/lib/review-queue-needs-key.ts
@@ -0,0 +1,11 @@
+import { serializeWorkspaceRoute } from "./workspace-route.js";
+
+export function reviewQueueNeedsKeyPath(input: {
+ readonly reviewerConfigured: boolean;
+ readonly estimatorsConfigured: boolean;
+}): string | null {
+ if (input.reviewerConfigured && input.estimatorsConfigured) {
+ return null;
+ }
+ return serializeWorkspaceRoute("agents");
+}
diff --git a/apps/studio/src/lib/sidebar-catalog-status.test.ts b/apps/studio/src/lib/sidebar-catalog-status.test.ts
new file mode 100644
index 00000000..764ffe44
--- /dev/null
+++ b/apps/studio/src/lib/sidebar-catalog-status.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from "vitest";
+import {
+ describeSidebarCatalogStatus,
+ type SidebarCatalogObservationInput,
+ type SidebarCatalogSourceInput,
+} from "./sidebar-catalog-status.js";
+
+function source(
+ venueId: string,
+ status: SidebarCatalogSourceInput["status"],
+ diagnostic: string | null = null,
+): SidebarCatalogSourceInput {
+ return { venueId, status, diagnostic };
+}
+
+function observation(
+ patch: Partial &
+ Pick,
+): SidebarCatalogObservationInput {
+ const healthySourceCount = patch.sources.filter(
+ (item) => item.status === "CURRENT",
+ ).length;
+ return {
+ healthySourceCount: patch.healthySourceCount ?? healthySourceCount,
+ sourceCount: patch.sourceCount ?? patch.sources.length,
+ listingCount: patch.listingCount ?? 600,
+ ...patch,
+ };
+}
+
+describe("sidebar catalog status", () => {
+ it("keeps the ready heading when every source is current", () => {
+ const status = describeSidebarCatalogStatus(
+ observation({
+ status: "READY",
+ listingCount: 600,
+ sources: [
+ source("polymarket-global", "CURRENT"),
+ source("kalshi", "CURRENT"),
+ ],
+ }),
+ );
+
+ expect(status).toMatchObject({
+ heading: "System ready",
+ tone: "ready",
+ countLabel: "2/2 sources · 600 markets",
+ refreshUseful: false,
+ refreshHint: "Catalog refresh is not required for source health.",
+ unhealthySources: [],
+ });
+ expect(status.hoverLabel).toContain("All catalog sources are current.");
+ expect(status.hoverLabel).toContain("2/2 sources · 600 markets");
+ });
+
+ it("names the missing source and says refresh may help", () => {
+ const status = describeSidebarCatalogStatus(
+ observation({
+ status: "DEGRADED",
+ listingCount: 600,
+ sources: [
+ source("polymarket-global", "CURRENT"),
+ source("polymarket-us", "CURRENT"),
+ source("kalshi", "CURRENT"),
+ source("gemini-predictions", "FAILED", "timed out"),
+ source("opinion", "CURRENT"),
+ source("myriad", "CURRENT"),
+ source("limitless", "CURRENT"),
+ ],
+ }),
+ );
+
+ expect(status.heading).toBe("Sources degraded");
+ expect(status.tone).toBe("degraded");
+ expect(status.countLabel).toBe("6/7 sources · 600 markets");
+ expect(status.unhealthySources).toEqual([
+ {
+ venueId: "gemini-predictions",
+ status: "FAILED",
+ statusLabel: "failed",
+ diagnostic: "timed out",
+ },
+ ]);
+ expect(status.refreshUseful).toBe(true);
+ expect(status.refreshHint).toBe(
+ "Catalog refresh on System overview may recover this source.",
+ );
+ expect(status.hoverLabel).toContain(
+ "Unhealthy: gemini-predictions (failed).",
+ );
+ expect(status.hoverLabel).toContain(
+ "Catalog refresh on System overview may recover this source.",
+ );
+ });
+
+ it("names every unhealthy source when more than one is down", () => {
+ const status = describeSidebarCatalogStatus(
+ observation({
+ status: "DEGRADED",
+ listingCount: 412,
+ sources: [
+ source("kalshi", "STALE_AFTER_FAILURE", "502"),
+ source("limitless", "NEVER_REFRESHED"),
+ source("opinion", "CURRENT"),
+ ],
+ }),
+ );
+
+ expect(status.unhealthySources.map((item) => item.venueId)).toEqual([
+ "kalshi",
+ "limitless",
+ ]);
+ expect(status.refreshHint).toBe(
+ "Catalog refresh on System overview may recover these sources.",
+ );
+ expect(status.hoverLabel).toContain(
+ "Unhealthy: kalshi (stale after failure), limitless (never refreshed).",
+ );
+ });
+
+ it("treats an idle catalog as refresh-next without claiming readiness", () => {
+ const status = describeSidebarCatalogStatus(
+ observation({
+ status: "IDLE",
+ listingCount: 0,
+ sources: [
+ source("polymarket-global", "NEVER_REFRESHED"),
+ source("kalshi", "NEVER_REFRESHED"),
+ ],
+ }),
+ );
+
+ expect(status).toMatchObject({
+ heading: "Catalog idle",
+ tone: "idle",
+ countLabel: "0/2 sources · 0 markets",
+ refreshUseful: true,
+ refreshHint: "Catalog refresh on System overview is the next step.",
+ });
+ expect(status.hoverLabel).not.toContain("System ready");
+ });
+
+ it("does not recommend another refresh while one is already running", () => {
+ const status = describeSidebarCatalogStatus(
+ observation({
+ status: "REFRESHING",
+ listingCount: 600,
+ sources: [
+ source("kalshi", "CURRENT"),
+ source("gemini-predictions", "FAILED", "connection reset"),
+ ],
+ }),
+ );
+
+ expect(status).toMatchObject({
+ heading: "Refreshing catalogs",
+ tone: "refreshing",
+ refreshUseful: false,
+ refreshHint: "Catalog refresh is already running.",
+ });
+ expect(status.unhealthySources[0]?.venueId).toBe("gemini-predictions");
+ });
+});
diff --git a/apps/studio/src/lib/sidebar-catalog-status.ts b/apps/studio/src/lib/sidebar-catalog-status.ts
new file mode 100644
index 00000000..9a139027
--- /dev/null
+++ b/apps/studio/src/lib/sidebar-catalog-status.ts
@@ -0,0 +1,163 @@
+export type SidebarCatalogSourceStatus =
+ | "NEVER_REFRESHED"
+ | "CURRENT"
+ | "STALE_AFTER_FAILURE"
+ | "FAILED";
+
+export type SidebarCatalogObservationStatus =
+ | "IDLE"
+ | "REFRESHING"
+ | "READY"
+ | "DEGRADED";
+
+export type SidebarCatalogSourceInput = Readonly<{
+ venueId: string;
+ status: SidebarCatalogSourceStatus;
+ diagnostic: string | null;
+}>;
+
+export type SidebarCatalogObservationInput = Readonly<{
+ status: SidebarCatalogObservationStatus;
+ healthySourceCount: number;
+ sourceCount: number;
+ listingCount: number;
+ sources: readonly SidebarCatalogSourceInput[];
+}>;
+
+export type SidebarCatalogTone = "ready" | "degraded" | "refreshing" | "idle";
+
+export type SidebarUnhealthySource = Readonly<{
+ venueId: string;
+ status: Exclude;
+ statusLabel: string;
+ diagnostic: string | null;
+}>;
+
+export type SidebarCatalogStatus = Readonly<{
+ heading: string;
+ tone: SidebarCatalogTone;
+ sourceCount: number;
+ healthySourceCount: number;
+ listingCount: number;
+ countLabel: string;
+ hoverLabel: string;
+ unhealthySources: readonly SidebarUnhealthySource[];
+ refreshUseful: boolean;
+ refreshHint: string;
+}>;
+
+const SOURCE_STATUS_LABEL: Readonly<
+ Record, string>
+> = Object.freeze({
+ NEVER_REFRESHED: "never refreshed",
+ STALE_AFTER_FAILURE: "stale after failure",
+ FAILED: "failed",
+});
+
+export function sourceStatusLabel(
+ status: Exclude,
+): string {
+ return SOURCE_STATUS_LABEL[status];
+}
+
+export function describeSidebarCatalogStatus(
+ observation: SidebarCatalogObservationInput,
+): SidebarCatalogStatus {
+ const unhealthySources = Object.freeze(
+ observation.sources.flatMap((source): SidebarUnhealthySource[] => {
+ if (source.status === "CURRENT") {
+ return [];
+ }
+ return [
+ Object.freeze({
+ venueId: source.venueId,
+ status: source.status,
+ statusLabel: sourceStatusLabel(source.status),
+ diagnostic: source.diagnostic,
+ }),
+ ];
+ }),
+ );
+ const tone = catalogTone(observation.status);
+ const heading = catalogHeading(observation.status);
+ const countLabel =
+ `${observation.healthySourceCount}/${observation.sourceCount} sources · ` +
+ `${observation.listingCount} markets`;
+ const refreshUseful =
+ observation.status !== "REFRESHING" &&
+ (observation.status === "IDLE" || unhealthySources.length > 0);
+ const refreshHint = catalogRefreshHint({
+ status: observation.status,
+ unhealthyCount: unhealthySources.length,
+ refreshUseful,
+ });
+ const hoverLabel = [
+ countLabel,
+ unhealthySources.length === 0
+ ? "All catalog sources are current."
+ : `Unhealthy: ${unhealthySources
+ .map((source) => `${source.venueId} (${source.statusLabel})`)
+ .join(", ")}.`,
+ refreshHint,
+ ].join(" ");
+
+ return Object.freeze({
+ heading,
+ tone,
+ sourceCount: observation.sourceCount,
+ healthySourceCount: observation.healthySourceCount,
+ listingCount: observation.listingCount,
+ countLabel,
+ hoverLabel,
+ unhealthySources,
+ refreshUseful,
+ refreshHint,
+ });
+}
+
+function catalogTone(
+ status: SidebarCatalogObservationStatus,
+): SidebarCatalogTone {
+ if (status === "READY") {
+ return "ready";
+ }
+ if (status === "REFRESHING") {
+ return "refreshing";
+ }
+ if (status === "IDLE") {
+ return "idle";
+ }
+ return "degraded";
+}
+
+function catalogHeading(status: SidebarCatalogObservationStatus): string {
+ if (status === "REFRESHING") {
+ return "Refreshing catalogs";
+ }
+ if (status === "IDLE") {
+ return "Catalog idle";
+ }
+ if (status === "DEGRADED") {
+ return "Sources degraded";
+ }
+ return "System ready";
+}
+
+function catalogRefreshHint(input: {
+ status: SidebarCatalogObservationStatus;
+ unhealthyCount: number;
+ refreshUseful: boolean;
+}): string {
+ if (input.status === "REFRESHING") {
+ return "Catalog refresh is already running.";
+ }
+ if (input.status === "IDLE") {
+ return "Catalog refresh on System overview is the next step.";
+ }
+ if (!input.refreshUseful) {
+ return "Catalog refresh is not required for source health.";
+ }
+ return input.unhealthyCount === 1
+ ? "Catalog refresh on System overview may recover this source."
+ : "Catalog refresh on System overview may recover these sources.";
+}
diff --git a/apps/studio/src/lib/venue-capability-chip.test.ts b/apps/studio/src/lib/venue-capability-chip.test.ts
new file mode 100644
index 00000000..391f0c62
--- /dev/null
+++ b/apps/studio/src/lib/venue-capability-chip.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { presentVenueCapabilityChip } from "./venue-capability-chip.js";
+
+describe("Markets venue capability chips", () => {
+ it("marks ORDER_GATEWAY as inert coverage when live execution is disabled", () => {
+ expect(presentVenueCapabilityChip("ORDER_GATEWAY", false)).toEqual({
+ key: "ORDER_GATEWAY",
+ label: "ORDER_GATEWAY · INERT",
+ inert: true,
+ });
+ });
+
+ it("does not restyle catalog coverage as an order surface", () => {
+ expect(presentVenueCapabilityChip("MARKET_CATALOG", false)).toEqual({
+ key: "MARKET_CATALOG",
+ label: "MARKET_CATALOG",
+ inert: false,
+ });
+ });
+});
diff --git a/apps/studio/src/lib/venue-capability-chip.ts b/apps/studio/src/lib/venue-capability-chip.ts
new file mode 100644
index 00000000..a0daf328
--- /dev/null
+++ b/apps/studio/src/lib/venue-capability-chip.ts
@@ -0,0 +1,32 @@
+export type VenueCapabilityChip = Readonly<{
+ key: string;
+ label: string;
+ inert: boolean;
+}>;
+
+export function presentVenueCapabilityChip(
+ capability: string,
+ liveExecutionEnabled: boolean,
+): VenueCapabilityChip {
+ if (capability === "ORDER_GATEWAY" && liveExecutionEnabled !== true) {
+ return {
+ key: capability,
+ label: "ORDER_GATEWAY · INERT",
+ inert: true,
+ };
+ }
+ return {
+ key: capability,
+ label: capability,
+ inert: false,
+ };
+}
+
+export function presentVenueCapabilityChips(
+ capabilities: readonly string[],
+ liveExecutionEnabled: boolean,
+): readonly VenueCapabilityChip[] {
+ return capabilities.map((capability) =>
+ presentVenueCapabilityChip(capability, liveExecutionEnabled),
+ );
+}
diff --git a/apps/studio/src/product-shell.css b/apps/studio/src/product-shell.css
index 1f5a8278..4bcf279a 100644
--- a/apps/studio/src/product-shell.css
+++ b/apps/studio/src/product-shell.css
@@ -772,12 +772,23 @@ pre,
font-weight: 550;
}
-.sidebar-status span:last-child,
+.sidebar-status > div > span:last-child,
+.sidebar-status-summary span:last-child,
.authority-note span {
color: #737c79;
font-size: 12px;
}
+.sidebar-status-detail {
+ padding-left: 18px;
+}
+
+.sidebar-status-detail p,
+.sidebar-status-detail li {
+ color: #737c79;
+ font-size: 12px;
+}
+
.topbar {
height: 64px;
border-color: rgba(40, 45, 51, 0.85);
@@ -2584,7 +2595,8 @@ footer {
.scout-heading,
.ai-rack-header,
.ai-rack-footer,
- .ai-runtime-panel-heading {
+ .ai-runtime-panel-heading,
+ .first-operator-step {
align-items: stretch;
flex-direction: column;
}
@@ -3595,7 +3607,7 @@ footer {
.inline-alert {
display: flex;
- align-items: center;
+ align-items: flex-start;
gap: 8px;
border: 1px solid color-mix(in srgb, var(--destructive) 45%, var(--border));
border-radius: 10px;
@@ -3604,6 +3616,54 @@ footer {
font-size: 13px;
}
+.inline-alert p {
+ margin: 0;
+}
+
+.inline-alert p + p {
+ color: var(--muted-foreground);
+ margin-top: 4px;
+}
+
+.inline-alert a {
+ color: inherit;
+ font-weight: 650;
+ text-decoration: underline;
+}
+
+.first-operator-step {
+ align-items: flex-start;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ display: flex;
+ gap: 18px;
+ justify-content: space-between;
+ margin: 12px 0 16px;
+ padding: 16px;
+}
+
+.first-operator-step strong {
+ display: block;
+ font-size: 14px;
+ margin-top: 2px;
+}
+
+.first-operator-step p {
+ color: var(--muted-foreground);
+ font-size: 13px;
+ line-height: 1.5;
+ margin: 4px 0 0;
+}
+
+.first-operator-step-actions {
+ display: flex;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
@media (max-width: 900px) {
.agent-console-grid,
.agent-control-form,
@@ -4182,12 +4242,19 @@ body {
font-weight: 550;
}
-.sidebar-status span:last-child,
+.sidebar-status > div > span:last-child,
+.sidebar-status-summary span:last-child,
.authority-note span {
color: #6f7874;
font-size: 13px;
}
+.sidebar-status-detail p,
+.sidebar-status-detail li {
+ color: #6f7874;
+ font-size: 12px;
+}
+
.topbar {
height: 64px;
border-bottom: 1px solid rgba(37, 42, 47, 0.9);
@@ -4732,7 +4799,8 @@ footer {
.scout-heading,
.ai-rack-header,
.ai-rack-footer,
- .ai-runtime-panel-heading {
+ .ai-runtime-panel-heading,
+ .first-operator-step {
align-items: stretch;
flex-direction: column;
}
diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md
index 87a95091..92899ea3 100644
--- a/docs/OPERATIONS.md
+++ b/docs/OPERATIONS.md
@@ -150,8 +150,10 @@ Then verify in Studio:
printed by the process.
- **Studio says offline:** check `http://127.0.0.1:4100/health` and the control
plane terminal output.
-- **Model unavailable:** inspect the runtime/credential posture in Readiness.
- The system should fail visibly; do not assume a hidden fallback.
+- **Model unavailable:** inspect the runtime/credential posture in **Agent
+ operations**. The Discover sidebar reports **System ready** source health, not
+ a Readiness panel. The system should fail visibly; do not assume a hidden
+ fallback.
- **DeepSeek setting appears ignored:** the SQLite configuration saved after
first startup overrides environment seed values. Change it in Studio or use
a fresh operational store intentionally.
diff --git a/docs/STUDIO.md b/docs/STUDIO.md
index d87468f5..f4326f85 100644
--- a/docs/STUDIO.md
+++ b/docs/STUDIO.md
@@ -289,8 +289,10 @@ dashboard together. Vite starts at `127.0.0.1:5173` and automatically advances
to the next free port. The dashboard uses a same-origin development proxy for
the control plane; always follow the URL printed by Vite.
-On first use, check readiness and storage posture before refreshing catalogs or
-starting a campaign. Catalog refresh is anonymous and does not call a model.
-Agent work begins only through an explicit action or an enabled durable
-scheduler. The selected provider, runtime capability, model, reasoning effort,
-campaign state, and usage lineage remain visible in Studio.
+On first use, open **Discover**. Check **System ready** source health, then
+**Refresh catalogs**. Catalog refresh is anonymous and does not call a model.
+If discovery dispatch is blocked, open **Agent operations** for the existing
+Codex session; do not expect **Explore next** to be the next click. Agent work
+begins only through an explicit action or an enabled durable scheduler. The
+selected provider, runtime capability, model, reasoning effort, campaign state,
+and usage lineage remain visible in Studio.
diff --git a/plans/studio-invalidation-stream.md b/plans/studio-invalidation-stream.md
index c610018a..046d47da 100644
--- a/plans/studio-invalidation-stream.md
+++ b/plans/studio-invalidation-stream.md
@@ -92,7 +92,7 @@ scheduling for the same event loop as evidence accumulates.
invalidation-only fanout and a per-revision cache, an idle sample fell to
0.2%; projection cost is now paid on operator demand instead of every effect.
- Studio retains the last good projection, allows at most one request in flight,
- collapses invalidations into one follow-up read, and exposes Live data,
+ collapses invalidations into one follow-up read, and exposes SSE connected,
Updating, Connecting, or Reconnecting in the product shell.
- Focused server/stream tests passed 28/28 and Studio projection tests passed
11/11. Full workspace type checks, all 576 tests, and the production build