---
title: CLI Pending Actions (approve external AI changes) (part 2)
---

# CLI Pending Actions (approve external AI changes) (part 2)

## What it is

This is part 2 of the [CLI Pending Actions (approve external AI changes)](cli-pending-actions.md) page. It covers everything that happens around the approval click rather than the card itself — how the asking session is told your decision, what the queue does when a row is never answered, the settings that can never be changed from outside, the rules around the note that travels with a request, and the one kind of card the app raises itself.

## Where to find it

Same inbox, same cards — this part is about the behaviour around them. The note that must accompany an external settings change is attached by the calling AI rather than set anywhere in the app, and the override card for a tagged branch arrives in the same **Approvals** section as every other row.

## How it behaves

### The session that asked gets told

When you approve or reject a request a **session** made, that session is told the outcome — so the agent isn't left guessing. If the source session is still **live or waiting on you**, a short notice appears right in its conversation ("The user approved the request you sent to their inbox — …" / "The user declined …"), rendered as a tagged **"injected by Inbox"** bubble, and the session **wakes** so the agent can react to your decision. If that session has already **finished** (ended, archived, or errored), nothing is injected — Omniscio never resurrects a done session just to deliver a notice. A request with no originating session (a cron-raised card, an outside `curl` with no session id) simply has no one to notify. **One notice per triage sweep, not one per click.** When a single session queued several cards, Omniscio holds each decision back while that session still has cards waiting on you, then sends **one** message covering the whole sweep — “The user approved 3 requests …” with each request listed, and approvals and declines split into their own groups when you did some of each. Deciding the session’s last card is what releases it, so the everyday single-card case still goes out immediately, exactly as before. Approving three cards used to wake the agent three separate times: the first notice starts a turn, so the next two arrived mid-turn and each forced another interruption to learn one thing. If you handle one card and leave the rest sitting, a 30-second backstop sends what you have already decided so nothing is stranded. This is the reverse of the **"Generated by ‹session›"** line on the card — the loop closes both ways. **Only a decision you actually made is reported.** When Omniscio applies a request _without_ you seeing it — an action that isn't gated and applies the moment it arrives, or one cleared by an **Always allow** you set earlier — nothing is announced to the agent as your decision, because you never made one. The notice marks **your** click, not the app's. Operators can turn it off with the `AMC_DISABLE_APPROVAL_DECISION_NOTIFY=1` env var. Single-source-of-truth: invariant I15 in [session-provenance-contract.md](/.claude/memory/contracts/session-provenance-contract.md).

### What if I miss the row?

The row stays in the inbox until you approve or reject it. There is no auto-expiry — pending rows live indefinitely. If you forget about them, the queue cap will eventually start blocking new external requests until you triage. The external AI can also cancel its own queued row via `DELETE /cli-pending/:id` (see [omniscio-control skill](/.claude/skills/omniscio-control/pending-actions.md)).

### What CAN'T be patched via CLI

A handful of self-destruct settings keys are denylisted server-side and return HTTP 400 unconditionally:

- `cliControl.enabled` — disabling this would kill the CLI server itself
- `cliControl.port` — changing this would orphan the running listener
- `requireGoogleAuth` — toggling the sign-in gate from outside is a footgun
- `apiKey` and `oauthRefreshToken` — credential surface, never patchable remotely

If you want to change one of these, do it from Settings inside Omniscio.

### Every CLI settings change must name its session

An external (command-line) settings change has to say **which session** requested it, by sending its `X-AMC-Source-Session-Id`. A request that doesn't identify a real session is **refused** (HTTP 400) before any inbox row is created — so a "Settings change" approval in your inbox always names the agent that asked for it, and you never get an anonymous one. This is a hard rule with no off-switch. (Omniscio's own agents send their id automatically; keyboard-shortcut keys are exempt — they apply immediately and never reach the inbox.)

### Attach a reason with `X-AMC-Approval-Note` (optional)

An external AI attaches a short free-text **reason** to an approval it creates, by sending an `X-AMC-Approval-Note` header with the request — e.g. `X-AMC-Approval-Note: the user asked to switch to the dark theme`. Omniscio stores it on the approval and surfaces it as a labeled **Note** — the last labeled row of the approval's details, in the same bordered card as its other facts — so you see the concrete change first and _why_ the agent is asking last. It is the agent's stated reason, never Omniscio's verification — the pane still shows the concrete change and which session requested it.

For a **settings change** (`PATCH /settings/:key`) the note is **required and must be concise**: an external AI's queued settings change with **no** note — or with a note **over 200 characters** — is **refused** (HTTP 400, nothing queued) so a "Settings change" in your inbox always explains itself in a short one-liner. An over-long note is refused, not silently trimmed — shorten it and retry. There is no off-switch. For **every other** approval kind (sends, deletes, spawns, tags…) the note is still **optional** (no note → no Note field) and isn't length-capped beyond a 1024-character safety bound. The note is sanitized and **informational only** — it never changes what the action does. Your own in-app UI is exempt (a person changing their own setting isn't asked to write a note), and approvals Omniscio raises itself (a cron failure, a budget warning) have no requesting agent, so they carry no note. Single-source-of-truth: [/.claude/memory/contracts/approval-note-contract.md](/.claude/memory/contracts/approval-note-contract.md).

### In-app session bypass

When an in-app Claude Code session (one Omniscio itself spawned) calls `PATCH /settings/:key` using its own session token, the patch applies inline — no inbox row, no approval prompt. This is intentional: your in-app sessions are already running with your permission, so adding a second layer of approval would be redundant. The bypass only works for the in-app token path; external AIs always go through the queue.

### The ready mint's override card (2026-09-08)

One row kind is raised by the app itself rather than by an AI: **`worktree.ready_override`** — the card titled _Approve tag over red evidence_. When a session runs `node scripts/worktree-ready.mjs "<summary>" --human-override "<reason>"` on a branch whose evidence gate is red — or whose roadmap-claim or tracked-ignored check refuses (those mint-only content checks bind the same card, one card naming every check it waives; the i18n code-proof and memory-doc cap take no override at all, because the auto-lander enforces both at land with no waiver) — the ready mint refuses (`403 override_not_permitted`) and raises this card, bound to that branch at that exact commit with the checks being waived and the reason. Approving it is the ONLY way an override can be minted: a token of any kind — including the global CLI token every session reads — is refused until a person clicks Approve. The same command re-run then stamps the tag, spending the card once (`override-approval: [<id>]` rides in the tag), and the auto-lander verifies that card before landing. A new commit needs a new card; rejecting the card is an answer, so no replacement is raised for that commit. One red is outside every override: when the evidence names a failing test in a guard security suite (the git-guardrails self-grant, push-approval-grant and overblock suites, or any test titled `[SECURITY]`), the mint refuses `403 override_cannot_excuse_security_suite` and raises no card at all — a security red is a live hole to fix, never debt to approve past, and the auto-lander refuses such an override tag at land too. It never appears in the "Always allow" list — its stand-down is a separate setting, not a CLI approval family.

That setting is **`autoApproveAgentReadyOverrides`** (default OFF; Dev Pipeline → Setup → Auto-lander, or `PATCH /auto-lander/ready-override-arming` with the machine's own full-trust CLI token — `PATCH /settings/:key` refuses it, and so does a scoped per-session agent token). Turn it ON and the mint approves the card itself instead of waiting for the click, including one that was already sitting pending from before the flip. Everything else holds: the card is still raised, still bound to that branch at that commit with those checks, still consumed once, and the auto-lander still verifies it before landing. An auto-approved row carries an `[auto-approved — no human click]` marker in its preview text so it is never mistaken for a click, and a card you REJECTED is still refused. If the setting cannot be read the mint refuses and raises the card, so a settings fault can never start auto-approving.

## For agents

### How it works

The shared queue lives in the `cli_pending_actions` SQLite table (created in migration v105 — see [/src/main/db/database.ts](/src/main/db/database.ts) — extended in v108 with a `dispatched_at` column, and later with a `dispatch_started_at` column for the at-most-once crash-recovery guard described below). Each row carries an `action_kind` (one of the 49 `CliActionKind` values), a `target_id` (the settings key, session id, project id, recipe id, or away-mode-rule id), a JSON payload, a `preview_text` (rendered into the inbox row title), a `status` (`pending` → `approved` → `rejected`), and a `client_request_id` for idempotency. The unique partial index `idx_cli_pending_request ON (client_request_id, action_kind) WHERE client_request_id IS NOT NULL` enforces the 30-day idempotency window — a retry with the same `(client_request_id, action_kind)` returns the prior row instead of duplicating it. CRUD lives in [/src/main/db/queries-cli-pending.ts](/src/main/db/queries-cli-pending.ts).

Beyond that id-based idempotency, the queue also dedups by **content**, so two identical approval cards can never sit in your inbox at once. Before inserting, `insertPending` checks for an already-OPEN row (still pending, within the 7-day display window) whose `(action_kind, target_id, payload_json)` is byte-identical to the new one; if it finds one, it returns that existing row instead of creating a second. This catches the case the id-based check misses: a caller that retries with a **fresh** `client_request_id` each time (the original duplicate-approval bug — an Omniscio session re-asked Omniscio to flip one setting twice, minting a new request id each attempt). It applies to every approval kind, not just settings — e.g. two identical session-spawn requests also collapse while pending. The one exception is the apply-immediately path (`applyCliActionNow`), which always acts on its own fresh row so it can never silently approve a card you're still deciding on. Single-source-of-truth: `no-duplicate-open-approvals` in [/.claude/memory/contracts/cli-approval-policy-contract.md](/.claude/memory/contracts/cli-approval-policy-contract.md).

The HTTP routes that queue rows are split by capability: settings PATCH is in [/src/main/services/cli/cli-server-settings-routes.ts](/src/main/services/cli/cli-server-settings-routes.ts), session lifecycle is in [/src/main/services/cli/cli-server-lifecycle-routes.ts](/src/main/services/cli/cli-server-lifecycle-routes.ts), and the queue inspection / cancel routes (`GET /cli-pending`, `GET /cli-pending/:id`, `DELETE /cli-pending/:id`) are in [/src/main/services/cli/cli-server-pending-routes.ts](/src/main/services/cli/cli-server-pending-routes.ts). Each mutation route runs the same pipeline: bearer-token auth → 10/min rate limit → kind-specific validation (for settings: the secret-key denylist plus a required `value` — a body that omits `value` is rejected `400` before it queues or applies; for sessions: a status precondition) → idempotency lookup (same `(client_request_id, action_kind)` within 30 days returns the prior row as HTTP 200) → queue-cap check → INSERT row → emit a `cli-pending:changed` push event → return HTTP 202 with the row payload.

The renderer subscribes to `cli-pending:changed` via [/src/renderer/src/hooks/useCliPushSync.ts](/src/renderer/src/hooks/useCliPushSync.ts), which calls `useCliPendingStore.handlePushUpdate()` from [/src/renderer/src/stores/cli-pending-store.ts](/src/renderer/src/stores/cli-pending-store.ts) on a 100 ms debounce. The store fetches the latest pending rows via the `CLI_PENDING_LIST` IPC handler in [/src/main/ipc/cli-pending-handlers.ts](/src/main/ipc/cli-pending-handlers.ts) and feeds them through [/src/renderer/src/stores/cli-pending-approval-items.ts](/src/renderer/src/stores/cli-pending-approval-items.ts), which materializes each row as a unified-inbox item with id `cli-pending-${row.id}` and title `${row.previewText}`.

**Which inbox section a row lands in** is decided by `deriveItemProjectId` in that same file. Most approval rows group under a generic **"APPROVALS"** bucket. Two exceptions get their own home: a `nighty_tidy.summary` row groups under its target audit **project**, and the two cron / scheduled-task **failure** notices — `cron.failure_alert` and the `cron.failure_heal` **escalation card** ("needs human attention") — group under the **cron job's originating project** (surfacing in that project's "Needs You") when it has one, or under a dedicated **"CRON JOBS"** section when the job is project-less. The originating project is _snapshotted into the payload at insert_ (the main process stamps `projectId: getCronJobProjectId(job)` in [/src/main/services/cron/cron-failure-alert.ts](/src/main/services/cron/cron-failure-alert.ts) and [/src/main/services/cron-heal-orchestrator.ts](/src/main/services/cron/cron-heal-orchestrator.ts)); the renderer only reads it. Single-source-of-truth: [/.claude/memory/contracts/cron-failures-inbox-contract.md](/.claude/memory/contracts/cron-failures-inbox-contract.md). The inbox-row click sets `activeApproval` on the session-store, which Dashboard reads to mount the approval pane from [/src/renderer/src/features/settings/sections/cli-pending-approval/CliPendingApprovalModal.tsx](/src/renderer/src/features/settings/sections/cli-pending-approval/CliPendingApprovalModal.tsx).

The pane renders the request through `FriendlyPayloadView` in [/src/renderer/src/features/cli-pending/friendly-payload/](/src/renderer/src/features/cli-pending/friendly-payload/) — a per-kind override for `settings.patch`, `recipe.run`, and `drip.update`, otherwise the generic `GenericPayloadView` that walks the payload's top-level keys, title-cases each label, resolves `*Id` / `*Ids` suffixes against eight entity stores (projects, sessions, tags, cron jobs, recipes, automations, AI-coaching artifacts, email rules), and formats by suffix (`*At` → timestamp, `*USD` / `*Usd` → dollars, `*Cents` → currency, `*Sha256` → short fingerprint, `*Path` / `*Url` → monospace), and renders a `cronExpression` as a single plain-English **Schedule** row via the pure `humanizeCronExpression` ([lib/cron-humanize.ts](/src/renderer/src/lib/cron-humanize.ts)) — the raw cron is never a labeled field (it stays in Technical Details), and the `drip.update` override reuses this same generic view to render an edited drip's `{ fields }`; single-source-of-truth [/.claude/memory/contracts/cron-approval-display-contract.md](/.claude/memory/contracts/cron-approval-display-contract.md). The raw JSON stays available behind the `TechnicalDetailsExpander`, a native `<details>` element keyed on the row id so it always opens fresh on a row switch. Title verbs and button labels are driven by `KIND_CONFIG` in [friendly-payload/kind-config.ts](/src/renderer/src/features/cli-pending/friendly-payload/kind-config.ts) — defaults to Approve / Reject (reason required); the ACK kinds (`cron.failure_alert`, `cron.failure_heal`, `session.budget_warning`, `session.budget_exceeded`, `nighty_tidy.summary`) flip to Acknowledge / Dismiss with no reason gate. A handful of kinds — agent-driven and deep-link session spawns, cron heal, cron failure alerts, and tag create / update / delete — early-return from the generic pane to bespoke panes (in [/src/renderer/src/features/cli-pending/](/src/renderer/src/features/cli-pending/)) that fetch extra context, but share the same title (no verb prefix for consent kinds, "Acknowledge:" for ACK kinds) and technical-details expander.
The pane renders the request through `FriendlyPayloadView` in [/src/renderer/src/features/cli-pending/friendly-payload/](/src/renderer/src/features/cli-pending/friendly-payload/) — a per-kind override for `settings.patch`, `recipe.run`, and the two Nighty Tidy run-now kinds (`nighty_tidy.run_now` / `nighty_tidy_2.run_now`, which show the human **Audit** name + a plain-English **Mode** line — "Read-only — won't change any files" vs "Read-write — can edit files to apply fixes" — and let the target-context block own the project, so the project never double-shows). Everything else uses the generic `GenericPayloadView`, which walks the payload's top-level keys, title-cases each label, resolves `*Id` / `*Ids` suffixes against **eleven** entity stores (projects, sessions, tags, cron jobs, recipes, automations, AI-coaching artifacts, email rules, **plugins, drips, saved prompts**), and formats by suffix (`*At` → timestamp, `*USD` / `*Usd` → dollars, `*Cents` → currency, `*Sha256` → short fingerprint, `*Path` / `*Url` → monospace, `*Slug` → the human name only with the "Slug" word dropped from the label and the redundant raw slug chip removed). A nested object is unrolled **one level** into labeled sub-fields (depth-capped — a deeper object collapses to a "more details" pointer) instead of dumping raw JSON or "(object)", and a small `NOISE_KEYS` denylist hides pure implementation-detail keys (the operator's local `sourcePath`, content hashes, optimistic-concurrency tokens, request ids) from the friendly body. (One latent bug fixed in passing: the away-mode rule cards resolved against the wrong payload key — `automationId` instead of the `ruleId` the route actually sends — so they showed a raw id; both keys now resolve.) When the card title already names its target (the consent session/project kinds — archive/pause/unpause/snooze, project delete/docs/bug-intake — per `titleNamesTarget` in [approval-target-entity.ts](/src/renderer/src/features/cli-pending/friendly-payload/approval-target-entity.ts)), the lead-in's name row is suppressed and `GenericPayloadView` drops any payload key whose value equals that target id, so `project.delete`'s `projectId` isn't re-rendered as a third copy of the project. The raw JSON stays available behind the `TechnicalDetailsExpander`, a native `<details>` element keyed on the row id so it always opens fresh on a row switch — hidden for the run-now kinds (whose tiny payload the override already shows in full) and whenever the friendly body renders nothing (empty `{}` archive/pause, or a delete whose only key is the lead-in target). The friendly body and the drawer-hide both read the same `renderedPayloadKeys` helper ([friendly-payload-keys.ts](/src/renderer/src/features/cli-pending/friendly-payload/friendly-payload-keys.ts), which also owns `NOISE_KEYS`) so the two can never disagree. Title verbs and button labels are driven by `KIND_CONFIG` in [friendly-payload/kind-config.ts](/src/renderer/src/features/cli-pending/friendly-payload/kind-config.ts) — defaults to Approve / Reject (reason required); the ACK kinds (`cron.failure_alert`, `cron.failure_heal`, `session.budget_warning`, `session.budget_exceeded`, `nighty_tidy.summary`) flip to Acknowledge / Dismiss with no reason gate. A handful of kinds — agent-driven and deep-link session spawns, cron heal, cron failure alerts, and tag create / update / delete — early-return from the generic pane to bespoke panes (in [/src/renderer/src/features/cli-pending/](/src/renderer/src/features/cli-pending/)) that fetch extra context, but share the same title (no verb prefix for consent kinds, "Acknowledge:" for ACK kinds) and technical-details expander.

The `settings.patch` override is the most involved of the per-kind paths because settings are the most varied shapes (top-level scalars like `theme: 'dark'`, deeply nested objects like the `aiManager` policy tree, lists of toolchain ids, dollar caps with a currency suffix). The PATCH handler in [/src/main/services/cli/cli-server-settings-routes.ts](/src/main/services/cli/cli-server-settings-routes.ts) reads `getSettings()[key]` _before_ inserting the pending row and writes the payload as `{ value, priorValue }` (absent keys coerced to `null`), so each row is self-describing — the prior is captured at the moment the AI submitted the request, not whatever the live store happens to hold later. The renderer's override prefers the payload's `priorValue` and only falls back to reading `useSettingsStore` for the rare case of a legacy row queued before this snapshot existed. The diff itself is computed by a pure `computeSettingsDiff(prior, next)` helper in [/src/renderer/src/features/cli-pending/friendly-payload/settings-diff.ts](/src/renderer/src/features/cli-pending/friendly-payload/settings-diff.ts) that walks the trees in lock-step and emits one entry per changed leaf path. The same diff feeds a pure `summarizeSettingsPatch(row)` helper ([settings-patch-summary.ts](/src/renderer/src/features/cli-pending/friendly-payload/settings-patch-summary.ts)) that produces the humanized, change-aware **headline** shared by the collapsed inbox row and the pane title (`Set Theme to dark`, `Turn <label> on/off`, `Change <label> (N settings)`); it resolves a plain-English setting label (curated map + `titleCase` fallback) and prettifies Electron accelerators (`CommandOrControl+Alt+J` → `Ctrl+Alt+J`) conservatively — and is wrapped so it can never throw into the inbox render path, falling back to the backend `previewText`. The settings-change card is **one field block where every datum is a labeled row** — **Type** ("Settings change"), **About** (what the setting does, when known), **Change** (the before/after), and **"Requested by ‹session›"** plus a separate **"Submitted ‹date›"** row (the session that asked, then the created date/time as its OWN labeled field — never crammed onto the byline; approval-standard-ui-contract I7) — matching the other approval cards. The raw key (e.g. `kmsQuickFindHotkey`) is never shown (it's jargon to a non-programmer; the title + headline already identify the setting), and a top-level boolean's Change renders as colored **Off → On** chips — Off red, On green — so the card shows both the current and new state at a glance (nested boolean leaves keep the value-based **Yes**/**No** green/red chip). An **array** value (a list setting like the pinned toolbar items) shows the concrete change — the **Added** (green) / **Removed** (red) items by their human label, resolved from the toolbar catalog so a pinned-item id reads "Settings", never the raw `settings` id and never a `(14-item array) → (15-item array)` count — and its headline reads `Change <label>` rather than "to (N items)" (contract I18, via [settings-array-display.ts](/src/renderer/src/features/cli-pending/friendly-payload/settings-array-display.ts); every list is bounded with a "+N more"). Single-source-of-truth for this feature is the contract at [/.claude/memory/contracts/settings-patch-approval-contract.md](/.claude/memory/contracts/settings-patch-approval-contract.md) — each invariant test-locked, plus a safe-change checklist for the next session. The Enter=approve / X=reject pane shortcuts are documented in the [Inbox Row Contract](/.claude/memory/contracts/frontend-inbox-row-contract.md).

When you click **Approve**, the modal calls the `CLI_PENDING_APPROVE` IPC, which routes through [/src/main/services/cli/cli-pending-dispatcher.ts](/src/main/services/cli/cli-pending-dispatcher.ts). The dispatcher re-validates the payload (status precondition still met? denylist still clean? target row still soft-deleted? slug still unique?), stamps a durable `dispatch_started_at` marker _before_ running the side effect (the at-most-once guard — see the reconciler below), then routes to the per-kind side effect through an auto-discovered handler module under [/src/main/services/cli-action-handlers/](/src/main/services/cli-action-handlers/) (one file per `action_kind` — e.g. `settings-patch-handler.ts`, `session-pause-handler.ts`, `project-delete-handler.ts`, `recipe-run-handler.ts`, `away-mode-create-handler.ts`), stamps `dispatched_at` (added in v108), flips the row's `status` to `approved`, and emits both a push for the row update and a domain push (`SETTINGS_CHANGED`, `SESSION_UPDATED`, `PROJECT_UPDATED`, `AWAY_MODE_CHANGED`, etc.). **Reject** flips `status` to `rejected` and emits the row push but no side effect.

**Permanent vs transient dispatch failures**: the dispatcher distinguishes errors that will never succeed on retry (target soft-deleted, recipe engine already running, schema-revalidation failed) from transient ones (DB lock, network blip). Permanent failures throw `PermanentDispatchError` from [/src/shared/cli-pending-types.ts](/src/shared/cli-pending-types.ts) — the dispatcher marks the row `rejected` instead of reverting to `pending`, so users don't loop forever clicking Approve on requests that can never succeed. Plain `Error` reverts to `pending` for retry.

**Crash-recovery reconciler — at-most-once** (F020 / RT-F004): on app startup, the dispatcher scans for rows the user approved but whose `dispatched_at` is still NULL — the process was interrupted somewhere between approving the row and finishing its side effect. It does **not** blindly re-run them. Each approved row also carries a durable `dispatch_started_at` marker, stamped _before_ the (often paid / irreversible) side effect runs. The reconciler reads it: a row whose dispatch **never started** runs for its one and only time, while a row whose dispatch **already started** — so the effect may already have fired (a real SMS sent, a paid recipe / session spawned) — is **finalized without re-running** (it just stamps `dispatched_at` and logs a line), so a crash mid-dispatch can never double-fire a side effect on the next boot. This gives the whole approved-action family at-most-once semantics, mirroring the durable origin-spawn-id guard in [/src/main/services/pending-cli-spawn-driver.ts](/src/main/services/pending-cli-spawn-driver.ts). (Earlier builds re-ran the dispatch unconditionally, which could double-apply a paid action after a crash.)

The cap is enforced atomically inside the same SQLite transaction that inserts the new row — `SELECT COUNT(*) WHERE status = 'pending'` followed by the INSERT — so two concurrent requests can never both squeak in to a queue one row below the cap.

## Related

The cards themselves — what one looks like and how you answer it — are on the [CLI Pending Actions (approve external AI changes)](cli-pending-actions.md) page, and the server these requests arrive on is [CLI Control](cli-control.md).
