diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index 43fed04a..74910bfc 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -29,6 +29,7 @@ import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboar
const Analytics = React.lazy(() => import('./pages/Analytics/Analytics'));
const OnboardingV3Root = React.lazy(() => import('./components/OnboardingV3/OnboardingV3Root'));
+const SignInRequiredGate = React.lazy(() => import('./components/overlays/SignInRequiredGate'));
const OnboardingRoot = React.lazy(() =>
import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })),
);
@@ -547,6 +548,9 @@ const ThemedApp: React.FC = () => {
+
+
+
diff --git a/frontend/src/app/components/overlays/SignInDialog.tsx b/frontend/src/app/components/overlays/SignInDialog.tsx
index 55a2498e..bfdf5eb4 100644
--- a/frontend/src/app/components/overlays/SignInDialog.tsx
+++ b/frontend/src/app/components/overlays/SignInDialog.tsx
@@ -24,7 +24,7 @@ type Stage = 'choose' | 'email_form' | 'code_form';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
-export default function SignInDialog({ onClose, initialStage = 'choose' }: { onClose: () => void; initialStage?: Stage }): JSX.Element {
+export default function SignInDialog({ onClose, initialStage = 'choose', mandatory = false }: { onClose: () => void; initialStage?: Stage; mandatory?: boolean }): JSX.Element {
const tokens = useClaudeTokens();
const dispatch = useAppDispatch();
const proxyUrl = useAppSelector(
@@ -156,7 +156,9 @@ export default function SignInDialog({ onClose, initialStage = 'choose' }: { onC
return (
-
-
-
+ {!mandatory && (
+
+
+
+ )}
{stage === 'code_form' ? (
<>
s.settings.loaded);
+ const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
+ const onboardingActive = useAppSelector((s) => s.onboardingV3.flowActive);
+
+ if (!shouldRequireSignIn({ settingsLoaded, userId, onboardingActive })) return null;
+
+ return undefined} />;
+}
diff --git a/frontend/src/app/components/overlays/shouldRequireSignIn.test.ts b/frontend/src/app/components/overlays/shouldRequireSignIn.test.ts
new file mode 100644
index 00000000..8248dc53
--- /dev/null
+++ b/frontend/src/app/components/overlays/shouldRequireSignIn.test.ts
@@ -0,0 +1,35 @@
+// Run: node --test frontend/src/app/components/overlays/shouldRequireSignIn.test.ts
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { shouldRequireSignIn } from './shouldRequireSignIn.ts';
+
+const base = { settingsLoaded: true, userId: null as string | null, onboardingActive: false };
+
+test('the veteran this exists for: settings loaded, no account, no onboarding', () => {
+ assert.equal(shouldRequireSignIn(base), true);
+});
+
+test('a signed-in user is never walled', () => {
+ assert.equal(shouldRequireSignIn({ ...base, userId: 'u-1' }), false);
+});
+
+test('nothing shows until settings are read, so a launch does not flash a login wall', () => {
+ assert.equal(shouldRequireSignIn({ ...base, settingsLoaded: false }), false);
+});
+
+test('a backend that never answers leaves the app usable rather than bricked', () => {
+ // settingsLoaded stays false forever in that case; the wall must stay down, not go up.
+ assert.equal(shouldRequireSignIn({ settingsLoaded: false, userId: null, onboardingActive: false }), false);
+});
+
+test('onboarding owns the screen, so the gate stands down while its own sign-in beat runs', () => {
+ assert.equal(shouldRequireSignIn({ ...base, onboardingActive: true }), false);
+});
+
+test('a fresh install that finishes onboarding signed in stays down afterwards', () => {
+ assert.equal(shouldRequireSignIn({ settingsLoaded: true, userId: 'u-2', onboardingActive: false }), false);
+});
+
+test('an empty-string user id is not an account', () => {
+ assert.equal(shouldRequireSignIn({ ...base, userId: '' }), true);
+});
diff --git a/frontend/src/app/components/overlays/shouldRequireSignIn.ts b/frontend/src/app/components/overlays/shouldRequireSignIn.ts
new file mode 100644
index 00000000..bdf28451
--- /dev/null
+++ b/frontend/src/app/components/overlays/shouldRequireSignIn.ts
@@ -0,0 +1,18 @@
+// Whether the mandatory sign-in wall should be up right now.
+//
+// Split out of the component so the branch that can lock every user out of the app is testable
+// without a React harness. See SignInRequiredGate.tsx for why the wall exists at all.
+export interface SignInGateState {
+ settingsLoaded: boolean;
+ userId: string | null;
+ onboardingActive: boolean;
+}
+
+export function shouldRequireSignIn({ settingsLoaded, userId, onboardingActive }: SignInGateState): boolean {
+ // Fails OPEN until settings are read: a backend that never answers must not brick a local-first
+ // app behind a wall the user's own saved account would have taken down.
+ if (!settingsLoaded) return false;
+ // Onboarding carries its own sign-in beat and its own curtain; stacking a second one hides both.
+ if (onboardingActive) return false;
+ return !userId;
+}
diff --git a/linter/config/config.json b/linter/config/config.json
index c15653e0..301b0886 100644
--- a/linter/config/config.json
+++ b/linter/config/config.json
@@ -20,7 +20,7 @@
"eslint-knip": "Node tooling deferred to a later pass.",
"classes": "Placeholder check, not wired up. endpoints: orphaned-endpoint triage deferred.",
"max-file-lines-exceptions": "Grandfather list of pre-existing >300-line files (existing debt, not new). Paths updated after the folder-tree restructure moved several of them. The two manager/prompt/* entries are from the agent_manager decomposition: prompt_context.py aggregates the system-prompt context builders and attachments.py is one cohesive 230-line attachment resolver; both are single-responsibility and a few lines over, not splittable without an artificial seam.",
- "max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles. 2026-07 desktop-shell additions: Dashboard canvas/cards/desktop + hooks/interaction + hooks/lifecycle, AgentChat bubbles/tool-ui, and shared/styles are flat peer collections (one component or hook per concern) that crossed 7 as the redesign surface grew. frontend/src/toolui carries a whole-subtree .lintignore: vendored tool-ui component library (pierre), same treatment as mcp-bundles. openswarm-edge/app is the edge's flat one-module-per-concern set (routing, bundles, inject, ratelimit, sandbox, and the vendored code_safety gate); it crossed 7 when the sandbox's static gate was split out to mirror the desktop file byte for byte. AgentChat/parsing joined when the narration/deliverable classifier landed: it is the same flat one-module-per-parser collection as the rest of that subtree. 2026-08-03 browser merge: agents/browser is the flat one-module-per-concern browser tier (40 modules) that arrived whole from eric/browser-merged; .github/workflows crossed 7 when the packaged-smoke and intel-verify workflows landed; frontend/ is a package root, not a code folder.",
+ "max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles. 2026-07 desktop-shell additions: Dashboard canvas/cards/desktop + hooks/interaction + hooks/lifecycle, AgentChat bubbles/tool-ui, and shared/styles are flat peer collections (one component or hook per concern) that crossed 7 as the redesign surface grew. frontend/src/toolui carries a whole-subtree .lintignore: vendored tool-ui component library (pierre), same treatment as mcp-bundles. openswarm-edge/app is the edge's flat one-module-per-concern set (routing, bundles, inject, ratelimit, sandbox, and the vendored code_safety gate); it crossed 7 when the sandbox's static gate was split out to mirror the desktop file byte for byte. AgentChat/parsing joined when the narration/deliverable classifier landed: it is the same flat one-module-per-parser collection as the rest of that subtree. 2026-08-03 browser merge: agents/browser is the flat one-module-per-concern browser tier (40 modules) that arrived whole from eric/browser-merged; .github/workflows crossed 7 when the packaged-smoke and intel-verify workflows landed; frontend/ is a package root, not a code folder. components/overlays is the flat one-component-per-overlay collection; it crossed 7 when the mandatory sign-in gate landed as component + pure predicate + its test.",
"import-cycles": "Flags RUNTIME circular imports only (SCC>1). Skips type-only imports (import type / export type) and dynamic import() since neither runs at module init, which is why the idiomatic Redux store<->hooks type cycle is not flagged. Frontend alias resolution comes from import-cycle-aliases. Zero cycles today; the check keeps it that way.",
"ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager behavior classes now inherit a typing-only AgentManagerProtocol base (manager/AgentManagerProtocol.py) that declares the composed __init__ state + cross-class methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated — App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.",
"no-underscore-names + p-private": "Convention checks ported verbatim from Haik's linter (haik/feat/ingest): no-underscore-names bans leading-underscore names (a dead-code-tooling blind spot; use p_ for private), p-private enforces that p_-prefixed names are accessed only inside their owning file/class (cross-file/class use means the name should be public). Backend Python only. The exception lists grandfather pre-existing debt that landed with the workflows/analytics forward-ports (eric's 'don't mass-migrate untouched files' rule); the agent_manager refactor surface is clean. NOTE: Haik's full linter (his branch also adds pyright + ruff and runs a different enabled set) should eventually supersede this; these two were lifted to enforce the p_ conventions on eric/dev now. browser_cookies.py and its Windows round-trip test are excepted for `_fields_` only: a ctypes.Structure protocol name required by the ctypes metaclass, not our naming.",
@@ -163,7 +163,8 @@
"backend/apps/onboarding/usage/browser_cookies.py",
"backend/tests/test_browser_agent_loop.py",
"backend/tests/test_browser_skills.py",
- "frontend/src/shared/state/settingsSlice.ts"
+ "frontend/src/shared/state/settingsSlice.ts",
+ "frontend/src/app/components/overlays/SignInDialog.tsx"
],
"max-folder-items": [
"backend",
@@ -208,7 +209,8 @@
"backend/apps/workflows/cloud",
"backend/apps/agents/browser",
".github/workflows",
- "frontend"
+ "frontend",
+ "frontend/src/app/components/overlays"
],
"no-nested-imports": [],
"import-cycles": [],