diff --git a/packages/desktop-app/src/lib/actions/core-modal.ts b/packages/desktop-app/src/lib/actions/core-modal.ts
new file mode 100644
index 000000000..1e1fa317a
--- /dev/null
+++ b/packages/desktop-app/src/lib/actions/core-modal.ts
@@ -0,0 +1,15 @@
+/**
+ * coreModal - Svelte action marking an element as one of core's open modals.
+ *
+ * Put it on the dialog element of a hand-rolled core modal, next to
+ * `focusTrap`. While the element is mounted `shell.modalOpen` (host API) is
+ * true; destroying the element releases it, so the signal follows the
+ * `{#if}` that shows the modal and cannot stick on. Not exported to
+ * extensions: their own modals must not count as core's.
+ */
+import { registerCoreModal } from '$lib/stores/core-modal-presence.svelte';
+
+export function coreModal(_node: HTMLElement) {
+ const release = registerCoreModal();
+ return { destroy: release };
+}
diff --git a/packages/desktop-app/src/lib/components/delete-confirmation-modal.svelte b/packages/desktop-app/src/lib/components/delete-confirmation-modal.svelte
index 482699b10..93bec2b14 100644
--- a/packages/desktop-app/src/lib/components/delete-confirmation-modal.svelte
+++ b/packages/desktop-app/src/lib/components/delete-confirmation-modal.svelte
@@ -1,6 +1,7 @@
@@ -13,6 +14,7 @@
aria-modal="true"
aria-labelledby="subtree-access-denied-title"
aria-describedby="subtree-access-denied-desc"
+ use:coreModal
use:focusTrap={{ onEscape: refusal.dismiss }}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
diff --git a/packages/desktop-app/src/lib/extension-api/index.ts b/packages/desktop-app/src/lib/extension-api/index.ts
index f6cf5510e..6f2383d4d 100644
--- a/packages/desktop-app/src/lib/extension-api/index.ts
+++ b/packages/desktop-app/src/lib/extension-api/index.ts
@@ -21,6 +21,7 @@ import type { CreatedNode, CreateNodeInput } from '$lib/services/backend-adapter
import { backendAdapter } from '$lib/services/backend-adapter';
import { sharedNodeStore } from '$lib/services/shared-node-store.svelte';
import { collectionsData } from '$lib/stores/collections.svelte';
+import { isCoreModalOpen } from '$lib/stores/core-modal-presence.svelte';
import { databaseStore, type DatabaseInfo } from '$lib/stores/database.svelte';
import { schemasData } from '$lib/stores/schemas.svelte';
import type { Node } from '$lib/types';
@@ -152,6 +153,26 @@ export const nodes: ExtensionNodes = {
)
};
+// --- Shell ----------------------------------------------------------------------
+
+/** State of the app shell that extension chrome can react to. */
+export interface ExtensionShell {
+ /**
+ * True while one of core's own modals (the first-launch wizard, the delete
+ * confirmation, ...) is on screen. A reactive read, usable in a contribution's
+ * `when()`. An `app-shell-modal` contribution shares the screen with these
+ * modals and should not open while this is true. It does not cover dialogs
+ * that extensions open themselves.
+ */
+ readonly modalOpen: boolean;
+}
+
+export const shell: ExtensionShell = {
+ get modalOpen() {
+ return isCoreModalOpen();
+ }
+};
+
// --- Collections and schemas ----------------------------------------------------
interface ExtensionReloadable {
diff --git a/packages/desktop-app/src/lib/stores/core-modal-presence.svelte.ts b/packages/desktop-app/src/lib/stores/core-modal-presence.svelte.ts
new file mode 100644
index 000000000..cdafbfb28
--- /dev/null
+++ b/packages/desktop-app/src/lib/stores/core-modal-presence.svelte.ts
@@ -0,0 +1,30 @@
+/**
+ * Whether one of core's own modals (the first-launch wizard, the delete
+ * confirmation, ...) is on screen. Extensions read it through
+ * `shell.modalOpen` in `@nodespace/extension-api` so an `app-shell-modal`
+ * contribution can wait for core's modals instead of stacking over them.
+ *
+ * Each modal holds one registration, released exactly once; the signal is on
+ * while any registration is held. Registering goes through the `coreModal`
+ * action, which releases on destroy, so a modal that unmounts (or whose
+ * component throws and is torn down) cannot leave the signal on.
+ */
+
+const held = new Set();
+
+let count = $state(0);
+
+/** Registers an open core modal. The returned function releases it; extra calls do nothing. */
+export function registerCoreModal(): () => void {
+ const token = Symbol('core-modal');
+ held.add(token);
+ count = held.size;
+ return () => {
+ if (held.delete(token)) count = held.size;
+ };
+}
+
+/** A reactive read: true while any core modal holds a registration. */
+export function isCoreModalOpen(): boolean {
+ return count > 0;
+}
diff --git a/packages/desktop-app/src/tests/components/core-modal-signal.svelte.test.ts b/packages/desktop-app/src/tests/components/core-modal-signal.svelte.test.ts
new file mode 100644
index 000000000..bc9fedaea
--- /dev/null
+++ b/packages/desktop-app/src/tests/components/core-modal-signal.svelte.test.ts
@@ -0,0 +1,121 @@
+/**
+ * `shell.modalOpen` (host API) is true exactly while one of core's hand-rolled
+ * modals is mounted, and is released on every exit path: close, unmount, and
+ * a registration released twice.
+ */
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { flushSync, tick } from 'svelte';
+import { render, cleanup } from '@testing-library/svelte';
+import { mockTauriCore } from '../helpers/mock-tauri-core';
+
+vi.mock('@tauri-apps/api/core', () =>
+ mockTauriCore({
+ invoke: (cmd: string) =>
+ Promise.resolve(
+ cmd === 'detect_agents'
+ ? { agents: [], detectionFailed: false }
+ : {
+ completed: false,
+ pathConfigured: false,
+ skillConfigured: false,
+ pathAlreadyConfigured: false
+ }
+ )
+ })
+);
+
+import { shell } from '@nodespace/extension-api';
+import { registerCoreModal } from '$lib/stores/core-modal-presence.svelte';
+import DeleteConfirmationModal from '$lib/components/delete-confirmation-modal.svelte';
+import SubtreeAccessDeniedModal from '$lib/components/subtree-access-denied-modal.svelte';
+import OnboardingWizard from '$lib/components/onboarding/onboarding-wizard.svelte';
+import {
+ confirmNodeDeletion,
+ getDeleteConfirmationState
+} from '$lib/services/delete-confirmation.svelte';
+import {
+ showSubtreeAccessDenied,
+ getSubtreeAccessDeniedState
+} from '$lib/services/subtree-access-denied.svelte';
+
+beforeEach(() => {
+ expect(shell.modalOpen).toBe(false);
+});
+
+afterEach(() => {
+ cleanup();
+ getDeleteConfirmationState().cancel();
+ getSubtreeAccessDeniedState().dismiss();
+});
+
+describe('shell.modalOpen', () => {
+ it('follows a registration and ignores a second release', () => {
+ const a = registerCoreModal();
+ const b = registerCoreModal();
+ expect(shell.modalOpen).toBe(true);
+ a();
+ a();
+ expect(shell.modalOpen).toBe(true); // b still holds it
+ b();
+ expect(shell.modalOpen).toBe(false);
+ });
+
+ it('is reactive: a derivation re-runs when it flips', () => {
+ const seen: boolean[] = [];
+ const stop = $effect.root(() => {
+ const open = $derived(shell.modalOpen);
+ $effect(() => {
+ seen.push(open);
+ });
+ });
+ flushSync();
+ const release = registerCoreModal();
+ flushSync();
+ release();
+ flushSync();
+ stop();
+ expect(seen).toEqual([false, true, false]);
+ });
+
+ it('is on while the delete confirmation shows and off once it resolves', async () => {
+ confirmNodeDeletion(1);
+ await tick();
+ render(DeleteConfirmationModal);
+ expect(shell.modalOpen).toBe(true);
+ getDeleteConfirmationState().cancel();
+ await tick();
+ expect(shell.modalOpen).toBe(false);
+ });
+
+ it('is on while the subtree-access-denied modal shows and off once dismissed', async () => {
+ showSubtreeAccessDenied(2);
+ await tick();
+ render(SubtreeAccessDeniedModal);
+ expect(shell.modalOpen).toBe(true);
+ getSubtreeAccessDeniedState().dismiss();
+ await tick();
+ expect(shell.modalOpen).toBe(false);
+ });
+
+ it('is on while the onboarding wizard is open, off when closed, and off after unmount', async () => {
+ const { rerender, unmount } = render(OnboardingWizard, {
+ props: { open: true, onClose: vi.fn() }
+ });
+ expect(shell.modalOpen).toBe(true);
+ await rerender({ open: false, onClose: vi.fn() });
+ expect(shell.modalOpen).toBe(false);
+ await rerender({ open: true, onClose: vi.fn() });
+ expect(shell.modalOpen).toBe(true);
+ unmount();
+ expect(shell.modalOpen).toBe(false);
+ });
+
+ it('is off when the modal unmounts while still pending', async () => {
+ confirmNodeDeletion(1);
+ await tick();
+ const { unmount } = render(DeleteConfirmationModal);
+ expect(shell.modalOpen).toBe(true);
+ unmount();
+ expect(shell.modalOpen).toBe(false);
+ });
+});