Asides (side question without polluting main thread)
Side questions you can ask an agent without polluting the main thread: how to enter aside mode and leave it, what the forked one-shot spawn behind it costs, where its settings live, and the limits — including why an agent can never send one.
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
- 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.
- Type your side question. Treat it like any other prompt — markdown, multi-line, attachments are fine.
- Press Send (or Ctrl+Enter). Omniscio checks what forking this conversation will cost, and if that is already over your cost cap (default
0.5USD) 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 below for why the pre-check is the one that actually saves money. - 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.
- 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 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(defaultfalse): 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(default0.5USD): the dollar value passed to the CLI as--max-budget-usdfor every aside spawn. The CLI will refuse to run a turn whose projected cost would exceed this cap. Set to0to 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.spawnof the Claude CLI. There is no aside "process pool" and the aside spawn never reuses or touches the persistent main-session process tracked inprocessManager.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-sessionclones the transcript. The--fork-sessionflag 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'scli_session_idis never mutated — when you send the next main-thread message, Omniscio resumes against the originalcli_session_id, which still has the pre-aside transcript.- Two database rows per aside, both tagged. The aside runner inserts two
conversation_messagesrows — operator (your question) + agent (the reply) — that share one freshly-mintedsidechain_group_idUUID. 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 itsresultevent. - Both rows pushed via
emitPush('session:output', …)withsidechainGroupIdcarried at the top level of the payload. The renderer'ssession-output-batcherandsession-storeforward this through to the message list, whereMessageList.buildRenderItemsgroups consecutive sidechain rows that share asidechain_group_idinto 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-usdcannot 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-usdis still passed, and still stops a fork that turns out to spend more than expected once it is running (set the setting to0to disable the cap entirely).
- 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
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:
- A main-thread message lands AFTER it. When
MessageList.buildRenderItemswalks the message list, it setshasMainThreadAfter=trueon anyAsideGroupwhose final row is followed by at least one row with nosidechainGroupId. TheAsideGroupcomponent reads that flag and renders withdefaultCollapsed=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. hideAsidesByDefaultis on. The setting forcesdefaultCollapsed=trueon 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_errorfires (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_errorfires 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 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 noaside_sent(or any aside-prefixed) event id, and the aside runner emits notrackEventcall. 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) 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, which /src/renderer/src/features/sessions/SessionPanel/SessionComposer.tsx renders while the mode is on. (The legacy never-rendered
AsideSendToggle.tsxpill 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
- Composer mode hook: /src/renderer/src/hooks/useAsideMode.ts
- Renderer-side message sync (sidechainGroupId forwarding): /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/useSessionPanel.ts (
handleSendAside; thehandleSendResponse→handleSendAsidedispatch whenaside.asideMode === truelives in the useSessionSend sub-hook) - /src/renderer/src/features/sessions/MessageList.tsx (
buildRenderItemssetshasMainThreadAfter) - /src/renderer/src/features/sessions/VirtualMessageList.tsx
- Settings UI (
hideAsidesByDefault,asideCostCapUsd): /src/renderer/src/features/settings/sections/session/SessionSettings.tsx - Main-process IPC handler (
session:send-aside): /src/main/ipc/session-handlers.ts - One-shot subprocess runner: /src/main/process/aside-runner.ts — spawns
claude -p --resume <id> --fork-session --max-budget-usd <cap>, persists operator + agent rows with a sharedsidechainGroupId, emitsSESSION_OUTPUTpush for each - CLI argument builder (where
--fork-sessionis added): /src/main/process/spawn-build.ts (buildSidechainCliArgs) - Database schema:
sidechain_group_id TEXT NULLcolumn onconversation_messages, added in migration v113 (renumbered from v111 at merge time) with index(session_id, sidechain_group_id)— see /.claude/memory/data-model.md### conversation_messages - Tests:
- Unit: /tests/unit/components/aside-group.test.tsx, /tests/unit/hooks/useAsideMode.test.ts
- E2E: /tests/e2e/ui/asides.spec.ts
- Integration (gated, real CLI spawn — costs money, opt-in): /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 — the cost-meter and silence settings interact with how asides surface (asides count toward your visible spend totals).
- 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 — 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).
Last verified 2026-10-05