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
approvalsSidebarEnabledinSIDEBAR_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 howalertsSidebarEnabledgates the Alerts row separately from the always-on alerts feature. - Delivered to mobile via the web bootstrap (
approvalsSidebarEnabledis inWEB_BOOTSTRAP_SETTING_KEYS), so hiding/showing it takes effect on the phone too. - Seeded automatically for existing users on the next launch (the
seedVirtualProjectsregistry 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
useCliPendingStorethe inbox reads, and the list is the genuine pending approvals throughuseApprovalsHubItems(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
sessionsslot and the approval pane is thesession-detailslot) — 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 thesession-detailslot 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 samecloseApprovalPaneAdvancing, 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 declaresCLI_PENDING_CHANGEDso 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-pendingintegration 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.uninstallrender 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, soU.S.or.apkgcannot 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/XCircleicon), 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