Omniscio documentation
Browse all documentation
  1. Getting Started19
  2. Sessions & Agents134
  3. Inbox & Notifications68
  4. Projects & Tasks98
  5. Automation & Scheduling74
  6. Knowledge & Memory28
  7. AI Features71
  8. Integrations112
  9. Plugins & Marketplace40
  10. Cloud & Teams52
  11. Settings & Customization76
  12. Account & Billing28
  13. Troubleshooting80
  14. CLI & API Reference28
  15. Legal & Policies5
  16. Uncategorised17

Start a new session — part 2 (how it works underneath)

Part 2 of the Start a new session page: how a launch actually runs underneath — the button and its IPC handler, the three guarantees on every launch, the pre-warmed blank that makes it feel instant, the staged launch-config pickers, the provider-default banner and the spawn itself.

What it is

This is part 2 of the Start a new session page. Part 1 covers what a session is, where to find the launch button and how to start one; this page is the engineering underneath a launch.

Where to find it

Nothing here is a separate screen: it is the machinery behind the launch button, the launch-config pickers and the session row, all described on part 1.

How it behaves

How it works

The "+" button lives in SessionsSidebar.tsx (look for launchTriggerRef and the data-tour="launch-session-button" attribute). It calls handleLaunchSession(), which first tries tryFastNewSession() (the same instant pre-warmed path Ctrl+T uses) and, on a miss or a specialised launch (SSH remote, chosen provider, forced isolation, OpenClaw), falls back to launchSession() on the renderer's session-store.ts, which invokes the SESSION_LAUNCH IPC channel. The keyboard shortcut path runs through useKeyboardShortcuts.ts, which dispatches the newSession action to handleNewSession in session-actions.ts — same tryFastNewSession first, same launchSession fallback, same error handling and retry, so toasts are identical between Ctrl+T and the + button. Default bindings live in keybindings.ts.

The IPC handler is in session-handlers.ts (IPC.SESSION_LAUNCH). For the regular Claude provider, it first calls findBlankSession() from queries-sessions.ts — if there's already a blank session in this project (zero non-system messages, no draft text, no draft images, no scheduled response, not in a recipe lane), Omniscio navigates to that session instead of creating a duplicate. Otherwise it falls into session-create.ts createSessionWithPrompt(), which: increments the project's session counter, writes the session row, optionally creates a git worktree (isolation), and calls processManager.launch() from process-manager.ts.

Three guarantees ride along with every launch. A failed launch is retryable in place: both launch paths attach a Try again toast action that re-runs the same launch — handleLaunchSession in SessionsSidebar.tsx for the + button, handleNewSession in session-actions.ts for Ctrl+T — with the humanized reason kept in the copy. An accepted request cannot be lost: the instant the row exists, session-launch-pending-intent.ts records a launch-pending intent on the existing recovery rail and clears it in the spawn tail's finally; if the process dies inside that window the row is left behind, and on the next start session-recovery-rearm.ts drives it again — with the user's own last message when there is one, and as an empty start when there is not (every other intent kind is still dropped when it has nothing to replay). The sidebar tells the truth the whole time: the optimistic blank row mints status: 'starting' with no CLI session id (session-store.ts, buildOptimisticBlankSession), and the SESSION_LAUNCHED push now carries the row's real status instead of a hard-coded ready, so a session with no process is never shown as ready. Invariants: durable-new-session-request-contract.md and session-start-visibility-contract.md.

Why it feels instant (the pre-warm). When you open a project, Omniscio quietly pre-warms one blank ready session for it in the background (a debounced SESSION_ENSURE_BLANK, auto-numbered "Session N" like any new session). So when you then hit + / Ctrl+T — or tap New Session on a phone — tryInstantNewSession() flips straight to that already-ready session with zero IPC, instead of waiting on the full spawn above (worktree + CLI process, which over a phone's tunnel is several seconds). This runs on desktop and mobile: a phone is a thin client of the same desktop main process, so the pre-warm does identical work, and skipping it on mobile (a bug fixed 2026-06-17) was what made mobile "New Session" feel slow. On the rare cold tap before a warm blank exists, the mobile button navigates to the session view immediately and then reveals the real, typable composer the moment the new session activates — the SESSION_LAUNCHED push activates the row before the ~7-8s worktree build finishes, so you start typing right away while the spawn completes in the background, instead of staring at a non-typable "Starting session…" spinner for the whole spawn. If the launch request later times out over the tunnel but the session was already created, you stay in that live session rather than getting bounced back — which is what finally makes the inbox-group + (always a cold tap, since those projects are never pre-warmed) as reliable as New Session. Gated by the Pre-load new sessions setting (Settings → Performance, on by default). Mechanism + invariants: keep-alive-pool-contract.md.

The launch-config pickers (shown on the "Session setup" card under the "What are we building?" hero — SessionEmptyState.tsx's NewSessionHero; the card renders only when the body actually hosts the pickers, so it is never an empty frame) reuse the same ChangeProviderButton + SessionModelThinkingChips, on desktop AND mobile. SessionPanel.tsx computes one bodyHostsStartConfig predicate — true on a fresh generic empty state with at least one control to show, keyed on zero NON-system messages (every new session is created with a "Session ready" system row, so the gate counts non-system messages — mirroring the backend sessionHasNonSystemMessages rule — never total messages.length; counting total was the 2026-06-07 regression that stranded the pickers in the header on every real new session) and not the bootstrap-seed / history-loading / Ask Omniscio / Search / first-mission sub-branches — from the pure helpers getEmptyStateProvider() (empty-state-provider.ts, which gates the chooser on the Show alternative AI providers toggle + isSwitchableProvider() — generalized from the old hardcoded claude/codex/gemini check, so deepseek/kimi/cursor/opencode/pi sessions also get a chooser) and resolveEmptyStateVariant() (empty-state-variant.ts). That single predicate drives BOTH the body render and the header yield: when the body hosts the pickers, the header's chooser (getHeaderChooserProvider, now gated on emptyStateActive) and its Model/Thinking chips are suppressed, so the pickers appear in exactly one place — never doubled, never vanished. The header keeps its pickers only as the fallback for the sub-branches where the body block does NOT render — a non-generic variant, history-loading, or a bootstrap-seed bubble; a plain blank session (only a "Session ready" system row) hosts them in the body. After the first message the header shows a static ProviderBadge + any read-only override badge; that static badge renders right after the session name on both desktop and mobile (before the snooze/recipe chips), default-inclusive so every session shows its engine mark (Claude in brand orange included), so a crowded screen can't shove it under the right-side toolbar (see mobile-in-session-provider-chooser-contract.md § "Static header badge"). Switching a session that turns out to already have history is rejected by the handler-side sessionHasNonSystemMessages guard, so the gate is a UI convenience, not the source of truth.

Because that block lives in the scrollable body, the mobile soft keyboard (which shrinks the layout viewport via interactive-widget=resizes-content) would otherwise clip it below the collapsed message area. SessionEmptyState.tsx listens for the keyboard to settle (a debounced visualViewport resize) and scrolls the body to its bottom — where the pickers sit — keeping them visible above the keyboard without relocating them out of the body (so the one-place invariant holds). Mobile-only; desktop is untouched.

Picking a provider / model / thinking does NOT hit the backend on tap — each pick is staged locally in pending-start-config-store.ts (key presence marks a field as staged; null = explicit "use default"). The staged config is applied in ONE step at first-message submit by applyPendingStartConfig (apply-pending-start-config.ts), called at the top of BOTH sendResponse and sendResponseAndNavigate so a first message sent any way (composer, voice) picks it up. SESSION_CHANGE_PROVIDER is therefore DB-only now (no eager terminate/relaunch — that would churn an engine the user may never use and double-spawn at submit); the first message's spawn reads the persisted provider/model/thinking fresh and starts the chosen engine exactly once. Invariants: start-config-staging-contract.md.

The Tool grouping is renderer-only presentation: picker-tools.ts derives the tool list from the registry's runtimeKind (Claude Code spans the claude + anthropic-compat providers, so a future compat engine folds in automatically), and the customize-list (pickerToolIds) / master toggle filter it. The backend stays per-provider — switching brains is an explicit Provider pick (which stages the provider via setProvider), and the Model dropdown then lists only that provider's models, so a Model pick is always same-provider. (The store keeps a cross-provider setProviderModel for other surfaces, but the in-session Model dropdown no longer reaches it.) The one exception is a session whose engine is a reseller host (an older DeepInfra session): its Model dropdown draws the cross-provider, deduped list and a pick under a DIFFERENT maker stages provider + model together via setProviderModel — see model-vendors.md. "Always use my default" (alwaysUseDefaultStartConfig) hides the block via a single hideStartConfig predicate in SessionPanel.tsx that suppresses it in BOTH the body AND the header (a body-only suppression would just bounce the picker up to the header), with a per-session revealStartConfigOnce flag behind the "Choose for this session" button that resets on session change.

The named default + "Save as default" banner route through one helper, provider-default-model.ts: getProviderGlobalDefaultModel resolves each engine's editable global default (claude → defaultModel; OpenCode/Pi → opencodeModel / piModel; every other engine → the providerDefaultModels map, falling back to the registry pin in provider-models.ts, validated against the engine's selectable list), and the build*Patch writers persist a promotion to the SAME key the read + the spawn (resolveSpawnModel) use — so the picker label can never disagree with what launches. Model, thinking, and provider/harness defaults are ALL promoted through one banner, SetDefaultHint, rendered below the pickers by SessionEmptyState.tsx and by the mobile create-on-send composer too (StartConfigPicker value mode with showDefaultHints, desktop parity). Each dimension is its own hook (useModelDefaultScopes / useThinkingDefaultScopes / useProviderDefaultScopes / useRunLocationDefaultScopes) that returns nothing while the current pick already matches the resolved default, so the banner appears only once you've actually changed something; when it does, it lists every changed dimension on one line with ONE SetDefaultScopeMenu offering that dimension's scopes: Model → Only this project · All (engine) sessions (a model is engine-bound), over the engine's built-in floor; Thinking → This model · All (engine) sessions · All (harness) sessions (multi-provider tools only) · Only this project · Everywhere (Claude's flat defaultThinkingLevel only); Provider/harness → Only this project · Everywhere; Run (desktop session mode only) → Only this project · Everywhere, writing projectDefaultRunLocations / defaultRunLocation (resolved by run-location-default.ts; RunLocationPicker pre-selects a saved Cloud at most once per blank session — start-config-staging-contract.md saved-run-location-default). The per-scope writers live beside their reads: model → buildProviderDefaultModelPatch / buildProjectDefaultModelPatch; provider → settings.defaultProvider / buildProjectDefaultProviderPatch; thinking → buildModelDefaultThinkingPatch (per-model) / buildProviderDefaultThinkingPatch / buildHarnessDefaultThinkingPatch / buildProjectDefaultThinkingPatch / the flat defaultThinkingLevel. The three thinking conditional layers fold in ONE place — resolveModelGlobalThinking (per-model → per-provider → per-harness → flat) — read by the Claude spawn, the Codex spawn, and the chip, so the pill can't drift from what launches. Promoting a MODEL or THINKING pick to a global-tier scope also clears the per-project shadow (buildClearProjectModelOverridesPatch / buildClearProjectThinkingOverridesPatch, merged into the same atomic write) so the broader default actually takes effect instead of staying silently shadowed — provider is the deliberate exception (a per-project engine choice is intentional, so promoting it never wipes a project pin). The menu tags the winning scope "In effect" (never a checkmark — a row whose action is "save here" must never look like it's already saved there) and shows each scope's currentValueLabel so you can see what it's set to before you overwrite it. There is no separate "clear without replacing" action in this banner — reverting a project's model/thinking pin back to inheriting the engine default means clearing that field in edit-a-project.md instead. Each chip also shows a source badge (sourceBadgeFor — amber "Project" / indigo "Session") whenever its value isn't the plain global default, so the "why did it revert to Luna" shadow is visible at a glance. Every write awaits the save and only toasts success once a store re-read confirms it took, so a failed save never shows a false confirmation (see ipc-error-surfacing-contract.md). Gemini/Codex/Cursor are seeded in the registry pin so the picker names a model on day one. Invariant: start-config-staging-contract.md model-pill-names-the-default/provider-chip-is-a-provider-picker/new-session-provider-precedence (provider) + set-default-hint-is-the-one-surface (SetDefaultHint is the one surface for a Model/Thinking default — the per-level editor superseded-per-level-editor introduced is retired).

The actual spawn is spawnLocalStreamJson() — same module. It calls pickActiveAccount() (synchronous, cache-only) which delegates to the account pool's tiered selection (pinned → primary capacity → extra-capacity → API key, gated by allowApiKeySessionSpawn). The chosen account's credentials get injected into the spawned CLI's environment as CLAUDE_CODE_OAUTH_TOKEN (login) or ANTHROPIC_API_KEY (api key). The CLI is spawned with --output-format stream-json --input-format stream-json --verbose --permission-prompt-tool stdio, with cwd set to the project's folder (or the worktree path if isolated). On every subsequent message in the same session, Omniscio re-spawns with --resume <cliSessionId> so multi-turn context is preserved. See process-management.md for the full lifecycle.

Related

  • Start a new session — Part 1: what a session is, where to start one, and the pickers.
  • Account pool — which account a spawn picks when you have several.
  • mobile-in-session-provider-chooser-contract.md — the invariant behind "one provider chooser on mobile, not two" (header chooser yields to the centred switcher)
  • start-config-staging-contract.md — why picking a provider/model/thinking is instant: picks are staged locally and applied at first-message submit (no engine spin-up on tap)
  • keep-alive-pool-contract.md — why launching a session is instant (the pre-warmed blank), on desktop AND mobile
  • durable-new-session-request-contract.md — why a session you asked for survives closing the app, and comes back with your own opening message
  • session-start-visibility-contract.md — why the sidebar never shows a session as ready before its process is actually up

Last verified 2026-10-17