---
title: Asides (side question without polluting main thread)
---

# Asides (side question without polluting main thread)

## What it is

Sometimes you're mid-conversation with an agent and you want to ask a quick clarifying question — "wait, what's the difference between X and Y?", "explain that error code", "what does this acronym mean?" — but you DON'T want that detour to become part of the main thread the agent will see on its next real turn. Stuffing the question (and its potentially long answer) into the transcript makes every following turn cost more tokens and gives the agent more rope to lose the thread.

**Asides** solve this. From any running session you turn on **aside mode** — via the session **⋯ overflow menu → "Aside Mode"** (or fire a single one via **Send as aside**). The composer flips into a muted "aside" mode — the textarea border turns amber, the Send button turns amber, and a visible **"Back to main chat"** button appears just below the composer. You type your side question and press Send. Omniscio spawns a one-shot Claude subprocess that **forks** the parent session's transcript (so the agent has full context to answer your question), gets back one reply, then exits. The Q/A pair is stored in your session's message history but tagged as a **sidechain** — visually distinct (a dashed-rail block with a small "ASIDE" pill on each bubble), and **completely invisible** to the next main-thread turn. The next time you send a normal message, the agent sees the transcript exactly as if the aside never happened.

In short: ask a side question, get an answer, move on — the main conversation stays clean.

## Where to find it

### How to use it

1. **Turn on aside mode from the session ⋯ overflow menu → "Aside Mode".** The textarea border and the Send button both turn amber, and a visible **"Back to main chat"** button appears just below the composer. **Entering aside mode also automatically focuses the composer textarea**, so you can start typing your side question immediately — no extra click needed. This is your signal that the next Send will be an aside, not a main-thread message. (There is NO keyboard shortcut to ENTER aside mode — the old Ctrl+B trigger was removed because it collided with editor-bold muscle memory and trapped users in the sticky mode with no obvious way out.)
   - **To get back out:** click the **"Back to main chat"** button, press **Escape** in the composer, or toggle "Aside Mode" off in the ⋯ menu. Any of them turns aside mode off immediately.
2. **Type your side question.** Treat it like any other prompt — markdown, multi-line, attachments are fine.
3. **Press Send (or Ctrl+Enter).** Omniscio checks what forking this conversation will cost, and if that is already over your cost cap (default `0.5` USD) it stops right there and tells you — nothing is spent. Otherwise it fires a one-shot CLI spawn: `claude -p --resume <parentCliSessionId> --fork-session --max-budget-usd <cap>`, passing the same cap to the CLI as a second ceiling. See [Cost-bounded in two places](#mechanics--what-happens-behind-the-scenes) below for why the pre-check is the one that actually saves money.
4. **Watch the answer arrive** as a single agent bubble inside a dashed-rail "aside" block, beneath your question. Both bubbles wear a small **ASIDE** pill so they're easy to tell apart from main-thread content.
5. **Continue the main conversation.** As soon as you (or the agent) post a new main-thread message AFTER the aside group, the aside auto-collapses into a `"N asides — click to expand"` divider — the rail disappears and the visual noise compresses to one line. Click the divider any time to expand the rail back.

**Aside mode is sticky.** Once on, the composer stays in aside mode across consecutive Sends — every Enter while the composer is amber fires another aside. To return to the main thread, click the **"Back to main chat"** button, press **Escape**, or toggle "Aside Mode" off in the ⋯ menu. Stickiness lets you fire several quick side questions in a row without re-toggling each time.

**One-shot alternative — no mode toggle.** If you just want to fire a _single_ aside without entering sticky aside mode, **press and hold the Send button** and choose **Send as aside** from the send-options menu that pops up. That sends whatever you've typed as one aside immediately (the same forked-context one-shot described above) and leaves the composer in its normal main-thread state — nothing turns amber, and your next Send is a normal message. Because it rides the Send button's press-and-hold (Pointer Events: mouse, touch, pen), it **works on touch** too (as does the ⋯ menu "Aside Mode" item). See [send-later.md](send-later.md) for the shared press-and-hold menu (its other item is Send Later).

### Settings

Open **Settings → Sessions** and look for these toggles. Both are findable via Ctrl+K (settings search) using keywords like `aside`, `sidechain`, `collapse`, `hide`, `cost`, `budget`, or `cap`.

- **`hideAsidesByDefault`** (default `false`): when on, every aside group renders **collapsed by default** as soon as it lands in the message list, regardless of whether a main-thread message has come after it yet. Click the divider to expand. Useful if you fire lots of asides and only want to peek at the rail occasionally.
- **`asideCostCapUsd`** (default `0.5` USD): the dollar value passed to the CLI as `--max-budget-usd` for every aside spawn. The CLI will refuse to run a turn whose projected cost would exceed this cap. Set to `0` to disable the cap entirely (NOT recommended on large transcripts — input-token cost grows with conversation length).

Per-session overrides are out of scope for v1; the settings apply globally.

## How it behaves

### Mechanics — what happens behind the scenes

The cleanliness of the main thread depends on a strict separation, enforced at multiple layers:

- **Per-aside one-shot spawn.** Each aside gets its OWN `child_process.spawn` of the Claude CLI. There is no aside "process pool" and the aside spawn never reuses or touches the persistent main-session process tracked in `processManager.sessions`. The aside child runs, replies once, and exits. This isolation means an aside can never accidentally kill, replace, or stall the main session's running process.
- **`--fork-session` clones the transcript.** The `--fork-session` flag tells Claude Code to take the parent session's full conversation history (selected via `--resume <parentCliSessionId>`), clone it into a new in-memory session id for this one-shot run, answer the side question against that clone, and then throw the clone away. The parent's `cli_session_id` is **never mutated** — when you send the next main-thread message, Omniscio resumes against the original `cli_session_id`, which still has the pre-aside transcript.
- **Two database rows per aside, both tagged.** The aside runner inserts two `conversation_messages` rows — operator (your question) + agent (the reply) — that share one freshly-minted `sidechain_group_id` UUID. The operator row is inserted FIRST, before the spawn, so the question is visible in the UI even if the spawn fails. The agent row is appended once the CLI emits its `result` event.
- **Both rows pushed via `emitPush('session:output', …)`** with `sidechainGroupId` carried at the top level of the payload. The renderer's `session-output-batcher` and `session-store` forward this through to the message list, where `MessageList.buildRenderItems` groups consecutive sidechain rows that share a `sidechain_group_id` into a single **AsideGroup**.
- **Belt-and-suspenders main-thread isolation.** Every SQL query that builds context for the next main-thread CLI spawn includes `AND sidechain_group_id IS NULL` (so sidechain rows never enter the result set), and every JS context builder filters with `!m.sidechainGroupId` (so any in-memory list passed to spawn-time logic skips them too). Either layer alone would prevent leakage; both run together so a future regression in one is caught by the other.
- **Cost-bounded in two places, and the first one is the one that saves money.** The aside fork starts with the FULL current transcript as input, so an aside's cost is roughly proportional to how long the main conversation already is. That first read is unavoidable and the CLI's `--max-budget-usd` cannot stop it: the CLI weighs its budget BETWEEN requests, so the very first request is never gated by it. Measured over 244 real asides, forks of large transcripts cost **$17.82–$19.53 each against a $0.50 cap** — the cap was not loose, it was unreachable.
  - **Before the spawn (the real ceiling).** Omniscio now prices what the fork will spend re-reading the transcript, and if that alone is already over your cap it **refuses without spawning** — nothing is billed, no rows are written, and the question you typed stays in the composer so you can send it the moment you raise the cap. The message names both numbers: *"This aside would cost about $13.40 just to re-read this conversation, which is over your $0.50 aside cost cap."* It appears in full in the error pop-up with an **Open Settings** button that jumps straight to the Aside cost cap setting (Settings → Sessions → Cost). The estimate counts uncached input only, so it is a floor rather than a forecast — an aside is only ever refused when it provably could not have come in under budget. Where the cost cannot be proven (cap set to `0`, or a model with no published rate) the aside runs, because this exists to stop a surprise bill, not to stand between you and a question.
  - **During the spawn.** `--max-budget-usd` is still passed, and still stops a fork that turns out to spend more than expected once it is running (set the setting to `0` to disable the cap entirely).

### Provider support — Claude-binary sessions only

An aside works by cloning the parent transcript with `claude -p --resume <cliSessionId> --fork-session`. That mechanism only works when the session runs the **Claude binary** with a Claude-resumable transcript — the native **Claude** engine and the **anthropic-compat** vendors (DeepSeek, Kimi, GLM, MiniMax) that run the same binary against a vendor endpoint. Those sessions can be aside-forked.

Sessions on an engine with its **own** runtime — **Codex, Gemini, Hermes, Kimi Code**, and the one-shot runners (Cursor, OpenCode, Antigravity) — store a **provider-native session id** the Claude binary can't resume, so the fork can't work. Omniscio refuses an aside on these up front (before writing any sidechain row or spawning), with a clear message: **"<Provider> sessions don't support asides — send a regular message instead."** Ask these sessions a question with a normal message (or the CLI `peer-message` endpoint) instead — it lands as a regular turn.

### An aside is YOURS — agents never use one

An aside is the human's side-question channel, and only that. Omniscio itself never uses one to
talk to an agent (owner rule, 2026-09-11), and the CLI route that let one agent aside another —
`POST /sessions/:id/peer-aside` — is retired; it answers `410` pointing at `peer-message`.

The reasoning is worth keeping, because it is a trap anyone could walk back into. A fork is
discarded, so the agent you "asked" remembers nothing about being asked. And the fork bills
against the aside cost cap: when the cap trips mid-answer the partial text is kept and the call
RESOLVES, so a programmatic caller receives an empty string with no error at all. For a person
reading the transcript that is a visible, recoverable annoyance. For an agent it is a silent
wrong answer. Agent-to-agent traffic goes over `peer-message`, which lands a real turn on the
target's own transcript. A structural guard (`tests/unit/lint/asides-are-human-only.test.ts`)
keeps it that way.

### Auto-collapse contract

There are two paths to a collapsed aside group:

1. **A main-thread message lands AFTER it.** When `MessageList.buildRenderItems` walks the message list, it sets `hasMainThreadAfter=true` on any `AsideGroup` whose final row is followed by at least one row with no `sidechainGroupId`. The `AsideGroup` component reads that flag and renders with `defaultCollapsed=true` — the dashed rail disappears and the user sees a one-line `"N asides — click to expand"` divider where the group used to be. This is the most common case: ask a quick question, get an answer, send your next normal message, the side conversation tucks itself away.
2. **`hideAsidesByDefault` is on.** The setting forces `defaultCollapsed=true` on every aside group at first render, irrespective of position. Even an aside at the very end of the transcript with no main-thread reply yet collapses to the divider.

Either path can be overridden interactively — click the divider to expand the group, click the rail header to collapse again. The expanded/collapsed state is in component-local React state, so it resets on session re-open.

### Failure handling — when something goes wrong mid-aside

The CLI signals turn-level failures via a `result.is_error: true` event. Omniscio's behaviour depends on whether real agent output streamed BEFORE the error event arrived:

- **Output streamed, then `is_error` fires** (e.g. budget cap tripped after the answer landed, post-stream tool error). You see the answer in the rail as normal, plus a small grey system bubble underneath: *"The aside hit a snag, but the answer above was kept. <reason>"*. The composer clears, no pop-up appears. Rationale: you already got what you asked for; surfacing a hard failure would be misleading.
- **`is_error` fires with no output streamed** (spawn failure, cap rejection, auth break). You see a system bubble in the rail — *"The aside couldn't be completed. <reason>"* — an error pop-up, and your typed text is restored to the composer so you can retry. Rationale: you have nothing useful to act on, so the UI gives you a clear retry cue.
  - **The budget ran out before any answer:** the pop-up shows the plain cost-cap message (*"Aside exceeded the $0.50 cost cap before answering. Raise it in Settings → Sessions → Aside cost cap…"*) with the same **Open Settings** button. It is a refusal, not a crash — it is not reported as an error.
  - **Anything else:** the pop-up says *"Could not send aside. The session may have ended."*

**Why the pop-up can show the full reason.** A refusal the app raises on purpose (over the cost cap, the budget running out, a vendor key missing, a model the vendor won't accept) is typed `UNPROCESSABLE`, and the error pop-up shows such a message exactly as written, however long. A missing vendor key also gets an **Open Settings** button, to Settings → Accounts. Before 2026-09-23 the pop-up cut every message over 200 characters, so a cost-cap refusal read "The session may have ended" (Sentry 7751440024). Contract: [ipc-error-surfacing-contract.md](/.claude/memory/contracts/ipc-error-surfacing-contract.md) `an-authored-refusal-is-shown-as-written`.

Process-level failures (spawn `ENOENT`, non-zero exit before any `result` event) follow the second path — they're indistinguishable from "no output streamed" from the user's perspective.

### Limitations (v1)

- **Mobile: enter via the ⋯ menu, exit via the button.** Aside mode works on touch — turn it ON from the ⋯ overflow menu "Aside Mode" item, and turn it OFF with the visible **"Back to main chat"** button. The button is the mobile way out, since Escape isn't available on a phone keyboard (that exit-on-mobile gap is exactly why the button was added when the Ctrl+B trigger was removed). One-shot **Send as aside** also works on touch.
- **No aside-from-aside.** You can't fire an aside FROM inside an aside group's bubble. Asides always branch off the main thread.
- **Tools enabled inside the aside subprocess.** The aside CLI runs with the same tool permissions as the parent session, so the agent CAN run a Read or Bash inside an aside if it decides to. This is what makes asides useful for debugging questions ("read this file and tell me X") — but it's also why the cost cap matters. The cap clamps total spend regardless of how many tools the aside uses.
- **No analytics tracking.** Asides are not recorded in `feature_events` — there is no `aside_sent` (or any aside-prefixed) event id, and the aside runner emits no `trackEvent` call. There's no per-aside cost or token attribution in v1 either.

### Cost notes

Because `--fork-session` clones the parent transcript, the **input cost of a single aside ≈ the input cost of one main-thread turn at the current transcript size**. If your main session has burned through a 100k-token conversation, every aside's input prompt is also ~100k tokens. This is fine for short, occasional side questions but can add up if you fire a dozen asides on a long session. The `asideCostCapUsd` setting exists specifically to put a per-spawn ceiling on this — the CLI refuses any turn whose projected cost exceeds the cap.

A reasonable mental model: an aside on a fresh session costs cents; an aside deep into a long session costs a fraction of the cap; the cap is the absolute worst case per side question.

## For agents

### Implementation pointers (for agents touching this code)

- Aside toggle affordances: the ⋯ overflow-menu "Aside Mode" item ([/src/renderer/src/features/sessions/SessionOverflowMenu.tsx](/src/renderer/src/features/sessions/SessionOverflowMenu.tsx)) turns it ON; the composer's **"Back to main chat"** button + Escape turn it OFF. The button lives in [/src/renderer/src/components/ui/AsideModeBar.tsx](/src/renderer/src/components/ui/AsideModeBar.tsx), which [/src/renderer/src/features/sessions/SessionPanel/SessionComposer.tsx](/src/renderer/src/features/sessions/SessionPanel/SessionComposer.tsx) renders while the mode is on. (The legacy never-rendered `AsideSendToggle.tsx` pill was deleted on 2026-09-07 — it still advertised the removed Ctrl+B chord.)
- Aside group rendering (rail + collapse divider): [/src/renderer/src/components/ui/AsideGroup.tsx](/src/renderer/src/components/ui/AsideGroup.tsx)
- Composer mode hook: [/src/renderer/src/hooks/useAsideMode.ts](/src/renderer/src/hooks/useAsideMode.ts)
- Renderer-side message sync (sidechainGroupId forwarding): [/src/renderer/src/hooks/useSessionSync.ts](/src/renderer/src/hooks/useSessionSync.ts)
- Renderer wiring (aside toggle, send-aside path, group-building):
  - [/src/renderer/src/features/sessions/SessionPanel.tsx](/src/renderer/src/features/sessions/SessionPanel.tsx)
  - [/src/renderer/src/features/sessions/useSessionPanel.ts](/src/renderer/src/features/sessions/useSessionPanel.ts) (`handleSendAside`; the `handleSendResponse` → `handleSendAside` dispatch when `aside.asideMode === true` lives in the [useSessionSend sub-hook](/src/renderer/src/features/sessions/useSessionPanel/useSessionSend.ts))
  - [/src/renderer/src/features/sessions/MessageList.tsx](/src/renderer/src/features/sessions/MessageList.tsx) (`buildRenderItems` sets `hasMainThreadAfter`)
  - [/src/renderer/src/features/sessions/VirtualMessageList.tsx](/src/renderer/src/features/sessions/VirtualMessageList.tsx)
- Settings UI (`hideAsidesByDefault`, `asideCostCapUsd`): [/src/renderer/src/features/settings/sections/session/SessionSettings.tsx](/src/renderer/src/features/settings/sections/session/SessionSettings.tsx)
- Main-process IPC handler (`session:send-aside`): [/src/main/ipc/session-handlers.ts](/src/main/ipc/session-handlers.ts)
- One-shot subprocess runner: [/src/main/process/aside-runner.ts](/src/main/process/aside-runner.ts) — spawns `claude -p --resume <id> --fork-session --max-budget-usd <cap>`, persists operator + agent rows with a shared `sidechainGroupId`, emits `SESSION_OUTPUT` push for each
- CLI argument builder (where `--fork-session` is added): [/src/main/process/spawn-build.ts](/src/main/process/spawn-build.ts) (`buildSidechainCliArgs`)
- Database schema: `sidechain_group_id TEXT NULL` column on `conversation_messages`, added in **migration v111** with index `(session_id, sidechain_group_id)` — see [/.claude/memory/data-model.md](/.claude/memory/data-model.md) `### conversation_messages`
- Tests:
  - Unit: [/tests/unit/components/aside-group.test.tsx](/tests/unit/components/aside-group.test.tsx), [/tests/unit/hooks/useAsideMode.test.ts](/tests/unit/hooks/useAsideMode.test.ts)
  - E2E: [/tests/e2e/ui/asides.spec.ts](/tests/e2e/ui/asides.spec.ts)
  - Integration (gated, real CLI spawn — costs money, opt-in): [/tests/integration/asides-cli-smoke.test.ts](/tests/integration/asides-cli-smoke.test.ts)

Visual style constants (in `styles.ts`): `ASIDE_BUBBLE_AGENT` (`bg-surface-800 text-surface-300 …`), `ASIDE_PILL` (small uppercase "ASIDE" badge), `ASIDE_RAIL_BLOCK` (dashed-rail wrapper around the group via `border-dashed`).

## Related

- [notifications-and-silence.md](notifications-and-silence.md) — the cost-meter and silence settings interact with how asides surface (asides count toward your visible spend totals).
- [cli-control.md](cli-control.md) — asides are not exposed via the CLI control server in v1; they're a renderer-only affordance. The CLI control server is documented separately for reference.
- [chat-attachments.md](chat-attachments.md) — image and document attachments work in the aside composer the same way they do in the main composer (passed through to the one-shot spawn's stdin).
