CLI Pending Actions (approve external AI changes) (part 2)
Part 2 of the CLI Pending Actions page: what happens after you decide — how the session that asked is told your answer, what the queue does when a card is never answered, which settings can never be patched from outside, the note rules, the in-app bypass, and the ready-mint override card.
What it is
This is part 2 of the CLI Pending Actions (approve external AI changes) 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.
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).
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 itselfcliControl.port— changing this would orphan the running listenerrequireGoogleAuth— toggling the sign-in gate from outside is a footgunapiKeyandoauthRefreshToken— 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.
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 — 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 CliActionKind values listed in CLI_ACTION_KINDS), 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.
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.
The HTTP routes that queue rows are split by capability: settings PATCH is in /src/main/services/cli/cli-server-settings-routes.ts, session lifecycle is in /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. 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, which calls useCliPendingStore.handlePushUpdate() from /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 and feeds them through /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 and /src/main/services/cron-heal-orchestrator.ts); the renderer only reads it. Single-source-of-truth: /.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.
The pane renders the request through FriendlyPayloadView in /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) — 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. 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 — 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/) 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/ — 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), 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, 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 — 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/) 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 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 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) 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; 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 — 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.
When you click Approve, the modal calls the CLI_PENDING_APPROVE IPC, which routes through /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/ (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 — 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. (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) page, and the server these requests arrive on is CLI Control.
Last verified 2026-10-02