Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

Approvals Hub

The Approvals hub is a sidebar hub that lists the pending CLI approvals — changes an agent or external tool asked Omniscio to make through the local control server — giving them a home on desktop and mobile instead of living only in the Inbox.

What it is

What it is: a sidebar hub (virtual project __approvals__, displayName "Approvals") that lists the pending CLI approvals — changes an agent or external tool asked Omniscio to make through the local control server: a settings change, a session pause/archive/spawn, a project delete, a recipe run, an SMS send, a cron failure alert, plugin/KMS actions, and so on. They are not the tool-permission prompts inside a session (those show on that session's own row). The hub gives these approvals a home in the Hubs list on desktop AND mobile, instead of living only in the Inbox.

Why it exists

Every other approval kind already has a hub (Cron Jobs, Automation Rules, Recipes), but the CLI (cli-pending) approvals were inbox-only — there was no hub to open them from. The Approvals hub closes that gap and mirrors the sibling Alerts hub.

Where to find it

Visibility

  • Gated by the UI-only setting approvalsSidebarEnabled in SIDEBAR_RENDER_GATES (project-visibility.ts) — default ON. Hiding it only removes the sidebar hub; the approvals themselves still ride the unified inbox and their approval pane. Mirrors how alertsSidebarEnabled gates the Alerts row separately from the always-on alerts feature.
  • Delivered to mobile via the web bootstrap (approvalsSidebarEnabled is in WEB_BOOTSTRAP_SETTING_KEYS), so hiding/showing it takes effect on the phone too.
  • Seeded automatically for existing users on the next launch (the seedVirtualProjects registry loop is idempotent) — no DB migration.

Placement

Nested under the "System" group (insights-group), next to Alerts.

How it behaves

How it works

  • The hub owns no data of its own. It is a two-pane surface, like the Alerts, Shares and Cron Jobs hubs: the list lives in Pane 2 (ApprovalsSidebar) and the picked approval is read on the right (ApprovalsVirtualProject), so opening an approval no longer covers the queue. Both read the SAME useCliPendingStore the inbox reads, and the list is the genuine pending approvals through useApprovalsHubItems (approvals-hub-items.ts).
  • The sidebar owns the loads. It loads the pending queue and the resolved history, and it is the only one of the two panes mounted on mobile (there the sidebar is the sessions slot and the approval pane is the session-detail slot) — so neither load may live on the reading pane, or a phone would show an empty queue forever.
  • Tapping a row calls activateUnifiedItem — the exact path the inbox uses — so approve/deny is the existing optimistic, chokepoint-guarded, paid-spawn-safe flow. On mobile the tap also advances to the session-detail slot where the approval pane mounts.
  • Approving or rejecting moves you to the next approval, the way handling a session moves you to the next one that needs you — so the queue can be cleared without returning to the list between rows. Handle the last one and the pane just closes.
  • In a popped-out Approvals window (Open in new window) there is no main-window overlay to draw the pane, so the READING PANE draws the approval itself (it asks isMainAppShell()); closing runs the same closeApprovalPaneAdvancing, so it steps to the next approval or back to the list. Before this a row there only highlighted. The popped-out list also stays live: the sidebar turns on the approvals reload (useCliPendingPushReload) outside the main window, where the app-wide one never runs, and the Approvals manifest declares CLI_PENDING_CHANGED so the pop-out receives it. See pop-out-project-window.md.
  • The reading pane's empty state keys on the PENDING queue, not on the history: it prompts "Select an approval" only while there is something to select, and says "No pending approvals" whenever the queue is empty — even when the history has rows, because a resolved approval is a read-only look-back and prompting to open one would point at nothing.
  • The cli-pending integration still owns the inbox source (cli-pending-approval); the hub is a presentation surface layered on top. Cron-failure NOTICES that share the cli-pending table are excluded (they bucket to their originating project / the Cron Jobs failures section, not to Approvals).

A card names what it acts on — never an internal id

A card's body is drawn from the row's payload, and an internal id printed there is a defect, not a detail: no-raw-id-in-user-facing-text-contract requires it to resolve to a human label at the render site, or the row is left out and the raw value stays in the Technical Details drawer.

  • A per-kind override satisfies that by construction. It REPLACES the generic iterator, so it draws only the rows it names and the raw key cannot reach the card at all (per-kind-overrides.tsx).
  • The plugin cards are the worked example (2026-09-30). plugin.install / plugin.enable / plugin.disable / plugin.uninstall render the plugin's name, a one-sentence What it does and, on an install, the version the card is pinned to. Before this the install card's one identity row was the raw plugin id (PLUGIN ID: pdf-viewer): the generic resolver reads the INSTALLED-plugin store, and an install card is by definition about a plugin that is not installed yet, so it fell through to the raw value.
  • Where the words come from. The card's own payload first — the install route stamps the bounded name and summary from the registry entry it already read, so the identity cannot depend on a second lookup that can fail — then the installed manifest, then the loaded marketplace catalog, read-only. A card never FETCHES to fill itself in.
  • A row that names nothing is omitted. The card's title already names a plugin on every queue path, so a missing name leaves the row out rather than falling back to the id.
  • Text from a plugin is untrusted. Names and descriptions are whitespace-collapsed and length-capped in one place (plugin-card-text.ts), and the description is cut to ONE sentence — ending only at .!? followed by a space and a capital, so U.S. or .apkg cannot split it.

Two sections: "Needs you now" + "Past approvals"

The sidebar stacks two sections (mirroring the Alerts hub's active + "Recently dismissed"):

  • Needs you now — the pending approvals still waiting on the user (the actionable rows described in How it works). When it's empty but history exists, a short "you're all caught up" line shows instead.
  • Past approvals — a collapsible, read-only history of already-handled approvals (approved or rejected), below the pending list, collapsed by default. Each row shows the request, an Approved/Rejected outcome label (semantic status colour + CheckCircle2/ XCircle icon), and when it was resolved. Rows are non-interactive — a resolved approval has no action, and (unlike the pending rows) it is never routed into the approval pane, which resolves only pending rows and would otherwise hang (the trap the Alerts dismissed-rows hit).

Where the past data comes from — no backend change. loadResolved on the cli-pending store fetches the resolved rows (status: 'approved' + status: 'rejected') through the SAME CLI_PENDING_LIST IPC the pending list uses — resolved rows are already served (the display-TTL window applies only to pending; resolved rows persist until the retention pruner). They land in a SEPARATE resolved store slice, so the inbox source filter and the badge (which read pending only) are untouched. cliResolvedSelectItems (cli-pending-approval-items.ts) maps them to read-only display rows through the SAME rowHeadline the pending list uses — which is why a legacy row queued before the local-time fix (2026-09-11) reads correctly HERE too: its stored preview carries a raw, cap-truncated until 2026-08-06T13:00, and the shared headline seam rebuilds the wake stamp from the row's intact payloadJson rather than from that text (the text cannot be repaired — a truncated fragment has lost its zone, and a zone-less value parses as LOCAL, so reformatting it would show the WRONG time). It excludes cron-failure notices via the SAME isGenuineApprovalRow predicate the pending mapping uses (so the two lists can never drift), reuses the pending list's title formatting, sorts newest-resolved first, and caps at RESOLVED_APPROVALS_DISPLAY_CAP (50). The section reloads on mount and whenever the pending count changes, so a just-handled approval drops into the history without a remount.

Badge count

The hub badges the total pending CLI approvals (a global roll-up, computed by countGenuinePendingApprovals in useProjectsSidebarCounts.ts). To avoid double-counting, cli-pending no longer contributes to the Cron Jobs hub's badge. An approval attached to a real project badges that project through the same rule that renders its row — both read the one registry and the one list, so the count is always what the hub shows when you open it. It still badges this roll-up as well: an approval legitimately shows in both places.

The fifth surface: the project hub the approval is attached to

Since 2026-09-25 an approval also appears in the Needs-You section of the project hub it is attached to — so a decision waiting on you is visible in the project you are actually working in, instead of only in this hub and the Inbox.

  • Which hub. The project the approval is attached to, resolved by one rule (approval-owning-project.ts): the project it targets; else the project of the session it targets; else the project named in its payload; else the project of the session that asked. A kind that acts on nothing of its own — a settings change, a recipe run — therefore lands in the asking session's project.
  • Never guessed. Anything that does not resolve to a real, live project (a virtual hub, a soft-deleted project, an archived asking session, an unparseable payload) keeps the generic bucket and simply shows on its existing surfaces. It is never attached to a wrong hub.
  • Same row, not a copy. The project hub renders it through the same attention-row path as every other notice, so approving from there runs the same chokepoint as here — one row, answerable from any surface, cleared from all the others.
  • It stays HERE too. Attaching an approval to a project never removes it from this hub: every approval appears in both places.
  • Worth knowing: middle-clicking a project hub row clears that hub's backlog, and for an approval that clear rejects it. That row class is already flagged irreversible, so the confirmation names it, lists it apart and opens with Cancel focused.

The fourth surface: the asking session's own conversation

Since 2026-09-08 an approval also renders as a card above the composer of the session that raised it — so the ask sits next to the message explaining it, and the user never has to leave the thread to answer. Same row, same chokepoint, same audit trail as the inbox and this hub; answering on any surface clears it from the others.

  • Scope: every kind the session raised, matched on sourceSessionId + status === 'pending'. It began as the git-guardrails break-glass only and the owner widened it, so an old note saying "guardrails only" is stale.
  • Several asks page, they do not pile up (2026-09-09). Two or more open approvals render as a swipeable deck — one card showing at a time, with an "Approval 2 of 4" counter and prev/next arrows above it, and a sideways swipe on touch. So the surface is one card tall no matter how many are waiting, and the conversation underneath stays readable. A single ask renders bare, with no pager. Before this they stacked, and three of them buried the message box.
  • Bounded: at most 10 cards in the deck, then a count + a link here. Since the deck no longer grows with the count, that cap is only about how many cards are worth putting on the page.
  • One card is never taller than a few lines (2026-09-26). The ask shows at most four lines and the agent's reason at most two, so Approve and Decline always stay on screen, even on a phone. Before this, a card whose ask listed twenty file paths grew taller than the phone and pushed its own buttons out of reach. The whole text is still in the card's tooltip and in Details. When several cards are up at once (say an approval plus a permission ask), they share one area that scrolls inside half the screen, so every button can always be reached.
  • Same safety gate: a destructive or money-spending kind clears the SAME confirm the inbox pane shows — one shared confirmDangerousApproval, so the two can never drift.
  • "Details" on a card opens the full pane here/in the inbox (where "always allow" and the raw payload live) and returns the user to the conversation once they answer.

Full invariants: in-conversation-approvals-contract.md.

For agents

Advancing to the next approval — the trap

The advance is the ordinary one: resolveInboxAction pre-advances the cursor with getNavigableItems(view, 'attention') + findNextItem. What is NOT ordinary is how the hub gets a nav list at all.

The hub is a virtual project, so no session carries its UUID, and its rows are unscoped CLI approvals whose projectId is the real project the approval is attached to, or the CRON sentinel when nothing on the row resolves to one (deriveItemProjectId) — never the hub's UUID, which is the only thing selectProjectAttentionItems matches on. The generic project path therefore returns an empty list for this hub, and an empty nav list silently disables the advance: the dispatcher captures no nav context and skips it. That was the 2026-08-30 bug — approve or reject from the hub and you stayed parked on the row you had just handled.

getNavigableItems now short-circuits on isApprovalsHubProject(projectId) and returns the hub's OWN queue from approvals-hub-items.ts — the same selector the sidebar renders, so nav order IS rendered order. That one seam also covers the X-dismiss advance and keyboard nav in the hub. Do not re-derive the queue anywhere else: getApprovalsHubItems is locked to the resolver by nav-group-single-source.test.ts.

The third trap — a row's group is not its project

A row's section and its project are two different things, and for an approval they now differ: every genuine approval is pinned to ONE Approvals group (so the queue stays contiguous), while its projectId names the real project it acts on. They used to be the same value for an approval, which made reading projectId as a group key safe by coincidence — and attaching approvals to their real projects ended that coincidence. findNextItem then cannot find the row's group, and the advance lands on an unrelated row: the "rejected an approval and it took me to the next project" bug.

Every place that groups, walks or advances between rows must therefore key on inboxItemGroupKey(item) (inbox-group-key.ts), which is item.inboxGroupId ?? item.projectId — the same expression the inbox grouping uses. Hand-writing that expression anywhere is the mistake this helper exists to end.

The second trap — the pane you just approved from is still alive

The desktop overlay keys each approval pane on the approval id inside AnimatePresence (compute-overlay-key.ts), so when a resolve pre-advances from approval A to approval B, A's pane stays mounted through its exit animation — still subscribed to its store. Its "my row is no longer pending → onClose()" auto-close fires the moment the optimistic approve lands. Until 2026-09-22 that close was context-free (closeApprovalPaneAdvancing() closed whatever approval was open), so it cleared B the instant the dispatcher selected it, and the passive visibility-recovery — which skips approvals by design — filled the slot with an alert: the 2026-09-21 "approving never selects the next approval" report.

The close is now self-scoped (approval-close-is-self-scoped in the inbox-navigation contract): closeApprovalPaneAdvancing(closing) requires the identity of the approval being closed and ignores one that is no longer open, and ActiveApprovalPane.tsx binds that identity once for every pane. Locked by approval-pane-stale-close.test.tsx (the real dispatcher, cron store and subscriber against a pane left on the approved row) and approval-close-advance.test.ts. Do not route a pane close to the chokepoint without the pane's own approval, and do not "fix" a stale pane by removing its auto-close — the push-resolved-elsewhere case needs it.

Related

Sibling pages: cli-pending-actions.md covers the CLI approvals this hub lists, inbox-alerts.md covers the unified inbox the same approvals ride, and agent-permission-level.md covers the permission levels that decide when a prompt is raised at all.

Last verified 2026-10-02