Deep Links (omniscio:// URLs) (part 2)
How deep links are implemented: the resolver behind them, what happens when an agent writes a link that does not resolve, the route each link takes into the app's navigation, and the contracts and tests that hold the behaviour in place.
What it is
This is part 2 of the Deep Links (omniscio:// URLs) page. It carries how links are resolved and routed in the code, moved here because a single page is capped at 40,000 characters.
Where to find it
Nothing on this page is a surface — part 1 has the link formats and where they take you. What follows is the code behind them, so it is for a reader with the repository open.
How it behaves
Every link form resolves through one resolver and one navigator, so a new link kind is a declaration rather than a new branch. Below is how a link is parsed, what a link that cannot resolve does instead of failing silently, and the contracts that lock it.
For agents
How it works
A link reaches the running app even when its data folder was moved. Only one copy of Omniscio runs per data folder, and a copy that runs from a moved folder (DATA_DIR, or --user-data-dir) registers its link handler with that folder built into the command. So a link opened from a program that doesn't carry the folder setting (an agent's shell, for one) still hands itself to the running app instead of starting a second copy on the default folder (Sentry 7759760656). The command comes from protocolClientLaunch() in /src/main/app/single-instance-policy.ts; a default-folder install and a named test/sandbox copy register as before, and the running app re-registers at every start. Invariant: a-link-launch-finds-the-running-app in single-instance-liveness-contract.md.
…and the hand-off brings its window to the front. The running app skips raising its window for a second launch that looks like a test or development run (a runner raise once froze it for 27 s), and it used to judge that by folder names alone — so where the app's own program files sit in the shared modules folder its test runs also use, every link hand-off looked like a runner: the app moved to the right place but stayed hidden behind other windows. The hand-off now carries the running app's own folder (the value ownLaunchEntry() in /src/main/app/single-instance-policy.ts returns, which the link registration also writes), and a launch carrying exactly that folder is the app relaunching itself, so the window comes forward; a run launched from a worktree never carries it and is still never raised. The check lives in /src/main/app/test-runner-invocation.ts. Invariant: a-link-hand-off-raises-the-running-app in single-instance-liveness-contract.md.
Protocol registration is done at app startup via Electron's app.setAsDefaultProtocolClient(...) — the app registers both the primary agentmc base (also the OAuth / Info.plist scheme) and the brand omniscio alias (each instance-suffixed on dev/sandbox). Outbound links the app builds carry the brand omniscio:// scheme (getInstanceProtocol() / computeBrandProtocolScheme in /src/main/services/deep-link.ts); the parser accepts either base (getAcceptedInstanceProtocols()), so an old agentmc://… link still routes. Inbound URLs arrive two ways: on macOS via the open-url event, on Windows/Linux via process.argv on a second-instance launch — both paths funnel into handleDeepLinkArgs(), which parses the URL with /src/main/services/deep-link.ts. Parsing is now forgiving: parseDeepLinkUrl() validates the scheme, then accepts the natural variants before extracting the route — plural hosts (settings→setting, notes→note, chats→chat, …), the ?q= query form for setting (matching the HTTP GET /settings/open?q= grammar), and a case-insensitive chat kind — and returns a typed DeepLinkAction. Every alias is additive — it only turns a former no-match into a match, never re-points an already-working link (locked by /tests/unit/deep-link-forgiveness.test.ts, which asserts each plural resolves identically to its singular). A URL that STILL doesn't parse is no longer a silent no-op: dispatchDeepLink shows a "couldn't open that link" toast instead of just focusing the window. To dry-run a link WITHOUT firing it, the CLI route GET /deep-link/resolve?url=<omniscio://…> returns the resolved action or a self-correcting "did you mean". Project refs resolve through the ONE shared matcher findProjectByRef() in /src/shared/project-ref-match.ts (exact name / id / sentinel path, then the fuzzy tiers); admissibility is the CALLER's: findActivatableProject() passes the whole project list because navigation reaches every project, while findProjectByName() pre-filters to spawnable projects for the paid spawn path. Main and the renderer share that one implementation — they used to be hand-kept copies that silently diverged on exactly this filter. For new-session actions, the main process consults the chokepoint routeAiSessionSpawnRequest() in /src/main/services/ai/ai-session-spawn-router.ts before emitting any renderer push: if requireApprovalForAiSessionSpawn is off (the default), it returns { kind: 'immediate' } and the main process emits DEEP_LINK_ACTION — no window-foreground steal (changed 2026-05-19, fix/spawn-no-focus-steal); the renderer's deep-link router then runs the spawn in the background (launchSession({ background: true })) and the user-visible affordance is a bottom-left "New session in <project>" toast with an "Open" action button (fired by handleSessionLaunched in /src/renderer/src/hooks/useSessionSync.ts off the SESSION_LAUNCHED push — it shows the prompt's opening words immediately, then swaps in the AI-generated title via updateToast if that lands within ~3 s; the older announceSpawn renderer-router path is superseded), so a drive-by agentmc:// link can never yank focus away from whatever the user is doing. In-app navigation is also suppressed (added 2026-05-21, fix/ai-spawned-session-no-focus): launchSession({ background: true }) threads a background: true field through the SESSION_LAUNCH IPC payload, the main process re-emits it on the resulting SESSION_LAUNCHED push, and /src/renderer/src/hooks/useSessionSync.ts's handleSessionLaunched skips the setActiveSession + project-switch writes when payload.background === true. The session still appears as a new sidebar row; whatever session you were reading stays on screen. Only the toast's "Open" button moves your view. If requireApprovalForAiSessionSpawn is on, the router instead inserts a session.spawn.deep_link row into cli_pending_actions (kindCap = 5 burst limit), emits CLI_PENDING_CHANGED, and the user approves/rejects inline via DeepLinkSpawnApprovalPane in the inbox — the same cli-pending-dispatcher that drives every other CLI-pending approval then emits the deferred push on approve. The renderer side lives in /src/renderer/src/hooks/useDeepLinkListener.ts and /src/renderer/src/lib/deep-link-router.ts, which translates the action into store mutations (activate project, launch session with prompt via launchSession({ background: true }), setActiveSession). Markdown-rendered agentmc://session/<id> URLs are intercepted by /src/renderer/src/components/ui/agent-markdown-path-utils.ts (parseAgentmcSessionUrl()) so they route internally instead of firing shell.openExternal. The fuzzy-match edge case — custom protocols don't auto-lowercase hostnames — is handled by manual normalization in the parser. The note route (agentmc://note/<relative-path>) parses to an open-note action; because it isn't the paid new-session path, the main process forwards it straight over DEEP_LINK_ACTION, and the renderer router activates the KMS virtual project (KMS_PROJECT_ID) then calls the KMS store's requestOpenNote, which stashes the path in pendingOpenNotePath and opens it from loadTree's (epoch-guarded) tail once the vault hydrates — so a cold link lands on the right note even before the panel has mounted. KMS-disabled (virtual project absent) and renamed/deleted notes (no match in the indexed tree) each surface a toast rather than a blank tab. The setting route (agentmc://setting/<query>) parses to an open-setting action carrying the raw query — the main process can't resolve it because the settings catalog lives in the renderer, so it forwards the query straight over DEEP_LINK_ACTION and the renderer router's open-setting case hands it to openSettingFromLink() in /src/renderer/src/lib/open-setting-from-link.ts. That glue resolves the query with the pure resolveSettingLinkTarget() in /src/renderer/src/lib/settings-link-target.ts — the same SETTINGS_SEARCH_INDEX + searchSettings() the Settings search box uses — then stages the resolved settingId via setPendingSettingId and calls navigateToSettingsProject({ section }), exactly mirroring the right-click "Open in Settings" affordance; useScrollToSetting then polls the DOM for [data-setting-id="…"] and scrolls + flashes it. It resolves in a fixed cascade — exact entry id → section id → exact settingsKey → fuzzy — and that order is load-bearing: the settingsKey tier deliberately sits BELOW the section tier so an all-lowercase key equal to a section id can never silently re-point a link that already worked. The fuzzy tier retries once with camelCase humps split into words, because a key folds to one long token that no tier of the search engine can match; that retry is also what produces the "did you mean" candidates (settingLinkCandidates, the single source both the user's toast and the CLI's didYouMean read from). The resolved settingId is always a known catalog id (never the raw link text), so the downstream querySelector is injection-safe; a fuzzy hit only scrolls when it's confident (label-exact or single result), and a no-match opens Settings + toasts. GET /deep-link/resolve reaches this same resolver through the window.__amcResolveSettingLink renderer bridge (installed beside __amcSearchSettings), so the endpoint's verdict and what a click actually does can never diverge — main cannot import the settings catalog, which is why it goes via the bridge. Invariants: setting-deep-link-resolution-contract.md. The in-chat path is the twin: parseAgentmcSettingUrl() in /src/renderer/src/components/ui/agent-markdown-path-utils.ts detects the URL in a rendered agent message and the CopyableLink handler calls the same openSettingFromLink(). Both call sites import the resolver + glue lazily so the settings catalog stays out of the chat-markdown and main-listener bundles until a setting link is actually followed. The supermail route (omniscio://supermail/thread/<id>) parses to an open-supermail-thread action; like note/setting it isn't the paid spawn path, so the main process forwards it over DEEP_LINK_ACTION and the renderer router (handleOpenSupermailThread) hands the thread id to openSupermailDeepLink('/thread/<id>') — the same activate-panel → force-Mail-tab → navigate flow a Supermail notification click uses (so it self-activates; the router does no redundant project switch). The one subtlety is the auth-vs-thread split: omniscio://supermail/auth (the OAuth JWT callback, which carries the token in its fragment) is intercepted BEFORE the generic parser at BOTH entry points — the single-instance.ts reopen handler and the index.ts cold-start argv scan — via the shared isSupermailAuthDeepLink() predicate in /src/main/ipc/supermail-deeplink-handler.ts and handled in main, while every OTHER supermail path falls THROUGH to the generic parser; and parseDeepLinkUrl() deliberately returns null for supermail/auth (only thread is a generic route) so the callback is never double-handled. A future supermail deep-link path must extend isSupermailAuthDeepLink only if it needs the main-process credential handling — otherwise it's a plain generic route. Tests in /tests/unit/services/ai-session-spawn-router.test.ts, /tests/unit/deep-link.test.ts, /tests/unit/deep-link-open-note.test.ts, /tests/unit/deep-link-open-superprompt.test.ts, /tests/unit/lib/deep-link-router.test.ts, /tests/unit/lib/deep-link-router-open-note.test.ts, /tests/unit/lib/deep-link-router-open-setting.test.ts, /tests/unit/lib/settings-link-target.test.ts, /tests/unit/lib/open-setting-from-link.test.ts, /tests/unit/features/kms/request-open-note.test.ts, /tests/unit/lib/deep-link-router-open-supermail-thread.test.ts, /tests/unit/main/supermail-deeplink-handler.test.ts, and /tests/unit/deep-link-renderer.test.ts cover every route + the OFF/ON branches + fuzzy tier + virtual-project filter + the note cold/warm/switch/not-found paths + the setting id/section/fuzzy/miss paths + the supermail thread route and the auth-vs-thread interception split. The inbox/item/<id> route (open-inbox-item) parses like the other inbox host paths; in the renderer router (handleOpenInboxItem) it navigates to the inbox, triggers a pending-approvals load (so a cold-start link has the row), opens that approval's pane via setActiveApproval({ kind: 'cli-pending', id }), and records a return-intent in /src/renderer/src/lib/approval-return-intent.ts. When that approval is later resolved, resolveInboxAction reads the intent (capture-before-await) and returns the user to the approval's source_session_id — suppressing the normal advance-to-next-approval — instead of advancing; it is gated so a plain inbox resolve is byte-identical (see the approvals-are-one-inbox-group sanctioned exception in the inbox-navigation contract). The link itself is emitted by the CLI approval gate via inboxItemDeepLink(row.id) in the queued 202 create-response body, so every gated agent approval carries it. Both inbox hosts are resolved by KIND before the router sees them, in resolveInboxCardAction in /src/renderer/src/hooks/useDeepLinkListener.ts — the wiring seam, NOT the router, because the router is deliberately store-free (inbox-approval-deep-link-contract → no-router-side-validation) and the loadInbox* deps it receives are typed () => void and wired as () => { void store.load() }, so a block-bodied arrow returns undefined and an await placed on them would settle immediately rather than after the load. The seam awaits the two REAL store promises, then: an open pending approval passes through untouched (today's path, byte-for-byte, so the return-intent still applies); an id that is an in-inbox alert is rewritten to the shipped open-alert action; and an id in neither list routes nothing at all and toasts. Routing nothing is the load-bearing half — filing the phantom {kind:'cli-pending'} selection is what the approval pane auto-closes on, and the cleared selection lets the idle auto-select take another row, which is exactly how a link to a card that was not there moved the reader somewhere else on the list. A load that FAILED, threw, or was superseded is a doubt and passes the action through unchanged rather than concluding the card is gone — and "failed" has to be read precisely here: both loads report whether they actually applied a fresh list, because a failed read comes back as a resolved { success: false } envelope rather than a throw, and each store leaves its previous (on a cold start, empty) slice in place. Trusting the await alone would read that empty list as authoritative and tell the reader a live card had been dismissed. The resolution then grades its own knowledge rather than putting everything short of certainty in one bucket, because an all-or-nothing doubt is itself a defect: it discards evidence the resolver already holds. One list being fresh and containing the id is proof on its own, checked BEFORE any doubt — so a live alert still opens when the pending read failed, instead of falling back to the approval path (which files the selection with no existence check, auto-closes, and lets the inbox auto-select move the reader to a different row). Absence only means "gone" when BOTH reads settled fresh and both miss it; anything less routes the original action untouched. And snooze is an answer, not a doubt: isInboxItemSnoozed is a synchronous read of a renderer-local store, and the pane the original action opens auto-closes on exactly that predicate, so a snoozed card is reported rather than routed — with BOTH kinds consulted, since the snooze record is keyed kind:id and a hit under either kind says what the card is as well as that it is hidden. Every other action still routes synchronously in the same tick. The GET /deep-link/resolve existence check gained matching open-alert and open-inbox-item cases in /src/main/services/deep-link-validation/existence.ts, both asked against the SAME two list calls the inbox opens from (listPending({ status: 'pending' }) and listActiveAlerts(db, { sourceKind: 'agent' })) so the check and the thing it checks cannot disagree — before them both hosts fell to default: UNKNOWN, which that file's own header defines as "assume the link is fine", and a live dry-run answered ok:true for both a card that was archived and an id that was invented. Neither case carries a didYouMean: both links genuinely open, so a suggested "repair" would wake an agent to rewrite a link that already works. POST /alert attaches the link only when the just-written card is actually in the inbox — the throttled path returns a REAL id of an already-ARCHIVED row and a held card is excluded from the inbox list, so gating on the id alone would have minted dead links the app authored itself.
Related
- Deep Links (omniscio:// URLs) — part 1 of this page, with the surfaces and the user-facing behaviour.
Last verified 2026-10-01