Skip to content

feat(cli): report run-config divergence instead of blocking on it - #333

Open
zxch3n wants to merge 23 commits into
mainfrom
feat/acp-run-config-no-blocking
Open

feat(cli): report run-config divergence instead of blocking on it#333
zxch3n wants to merge 23 commits into
mainfrom
feat/acp-run-config-no-blocking

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #324 (which is stacked on #316). Review those first; the base flips as they merge.

This is step 1 of the ACP run-config plan: no capability evidence blocks a turn any more. It needs no new data and, on its own, removes the reported failure.

Why the old rule was wrong

A capability probe starts the agent, sends one session/new, and stores the returned configOptions. That array describes the model that was current at that moment — and scheduleCreatedSessionCapabilityUpdate rewrites the whole row on every session this machine creates, so in practice it describes whichever model ran last. It was nonetheless the authority for rejecting a turn: an id it omitted was Unknown ACP config option, a value outside its list was Allowed values: …, an unseen model or mode was Unsupported.

None of that is evidence about the model the turn selects. ACP has no way to express per-model availability — an option is simply absent when the current model lacks it — and no way to ask what another model would offer.

What changed

Snapshots report; they do not reject. validateTurnConfigOptionValues now rejects only what no model could carry: a value the option's own declared TYPE forbids (a boolean handed a string, a select handed a boolean). Unknown ids, out-of-list values and unseen model/mode ids are dispatched and reconciled against the state the agent publishes. findUnverifiedTurnSelectors and unverifiedSelections record what could not be confirmed; neither blocks.

The per-model exemption machinery is deleted, not maintained. validatedConfigIds, the skip set and resolvePerModelConfigOptionSelection (from #316) existed only to carve out the cases where the snapshot would wrongly reject. With rejection gone there is nothing to exempt. A published per-model breakdown still CONFIRMS a value — that is what keeps it out of unverifiedSelections — it just never rejects one.

A missing wire binding still fails loudly, and says so. When neither the snapshot nor the agent's own convention says how to spell a control, there is no request to send and inventing an id would be a silent no-op. A semantic fastMode for an agent we have no binding for now reports that it cannot be encoded, not that it is unsupported.

Two defects found while doing it

Permission-bearing config was applied first and silently widened. The applier set the mode before the model; Claude rebuilds its permission modes on a model switch and downgrades the current one to default when the new model lacks it. A turn asking for plan/read-only and switching models ran with wider permissions than requested. Model and ordinary options now go first, permission-bearing ones last. applyPromptConfig already runs before prompt, so the state read afterwards is still taken before the agent acts.

The runtime patch echoed the request back as the outcome. A successful session/set_mode overwrote the agent's reported mode with the requested one, so no mode divergence could ever be reported — and it is why the ordering bug was invisible. It now only FILLS a mode the agent's own state does not report, mirroring what the model branch already did. The regression test fails when the ordering is reverted; the fixture that hid it now reports what it accepted, like a real agent.

Client side

A guess could become a durable promise. applyAgentRoleRunConfigDefaults seeded new Roles from provisional capabilities — the built-in static tables, a hand-maintained copy of a catalog the agent fetches per account. That is how a Role came to promise Fast mode for an agent whose probe never published the option. Only authoritative capabilities seed now.

And the promise then became invisible. The composer's authoritative branch dropped every key outside its selector catalog, so a Role pinning Fast on a fast-capable model had its own value removed from the composer meant to show what the Role will do — and removed again from the dispatch table. Stored keys with no selector are kept while no runtime table exists; a present runtime table (the agent's live state, not another model's snapshot) still owns the whole key set.

Compatibility

Strictly loosening for existing data: stale caches, legacy entries and Roles carrying values the current snapshot does not list all stop being rejected. Roles and frozen Operations behave exactly like ordinary preferences at run time — an upgrade must never turn a Role that used to run into one that fails, and that is now written into apps/cli/AGENTS.md along with both new invariants.

pnpm typecheck, pnpm lint, check:public-boundary, and the shared (1018) / CLI (2468) / components (3018) suites all pass.

Not in this PR

Steps 2 and 3 — having the two built-in adapters declare their per-model capabilities through ACP _meta, then deleting the hardcoded max/ultra injection once telemetry shows coverage. Those need adapter submodule releases and a coverage gate.

🤖 Generated with Claude Code

zxch3n and others added 6 commits September 3, 2026 02:22
A probe's `configOptions` only describe the model that was current when it
ran: Codex publishes `fast-mode` only while that model has a fast speed
tier, and rebuilds the effort list on every model switch. Dispatch already
knew this for a SEMANTIC selection, but concrete ids that never pass through
that resolver — an Agent Role's stored `runConfig`, `--config-option`, a
frozen Operation replayed after recovery — were still checked against the
probed snapshot, so a Role pinning a fast-capable model failed create with
"Unknown ACP config option for the selected agent: fast-mode" whenever the
agent's default model had no fast tier.

Apply the same rule to the values themselves. `resolvePerModelConfigOptionSelection`
exempts per-model ids from the snapshot check when the turn runs a model other
than the probed one, still validating effort strictly wherever the agent
published its per-model breakdown, and reports the rest as unverified rather
than rejecting them. A turn that runs the probed model keeps the snapshot
authoritative, so a genuinely missing control is still refused.

Also record the fast toggle in `validatedConfigIds` on a model switch, which
the effort branch already did.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The applier suppressed user-visible warnings for exactly the four controls
users most need told about — model, reasoning effort, Fast, Plan — on Codex
and Claude, because it keyed the notice off a REJECTION and those calls fail
routinely when re-sent per turn. But a rejection is the wrong signal in both
directions. Codex ACCEPTS `fast-mode` on a model with no fast speed tier and
then omits the option from the state it publishes: nothing throws, the turn
runs at normal speed, and the user is told nothing. Meanwhile a rejected
value that was already effective changed nothing and was reported.

Key the notice off divergence instead: after applying the turn's config,
compare each requested selection against the state the agent itself
publishes, and warn only where they disagree. An `on`/`off` select and a
boolean toggle are the same choice, so neither shape alone is a divergence.
Where the agent published no config options at all — or for a sensitive id,
which the runtime state deliberately omits — the failed call remains the only
signal, so it is still used there.

Rejections keep going to debug diagnostics unchanged, and `agent_warning`
notices are already deduplicated by message per session, so a stuck
divergence costs one notice rather than one per turn.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A probe's `configOptions` describe the model that was current when it was
captured — and that snapshot is rewritten by every session this machine
creates, so it describes whichever model ran last. It was nonetheless the
authority for rejecting a turn: an id it omitted was "Unknown ACP config
option", a value outside the list it recorded was "Allowed values: …", and a
model or mode it had not seen was "Unsupported". None of that is evidence
about the model the turn actually selects.

Snapshots no longer reject anything. What stays a local error is only what
cannot be dispatched under any model: a value the option's own declared type
cannot carry. Everything else is sent as requested and reconciled against
the state the agent publishes, which is the only thing that knows.

The per-model exemption machinery goes with it. It existed to carve out the
cases where the snapshot would wrongly reject; with rejection gone there is
nothing to exempt, so `validatedConfigIds`, the skip set, and
`resolvePerModelConfigOptionSelection` are deleted rather than maintained.

One thing still fails loudly, and it is a different statement: a missing
wire BINDING. When neither the snapshot nor the agent's own convention says
how to spell a control, there is no request to send and inventing an id
would be a silent no-op — so a semantic `fastMode` for an agent we have no
binding for reports that it cannot be encoded, not that it is unsupported.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… own mode

Two defects, one visible only because of the other.

The applier set the permission mode BEFORE the model. Claude rebuilds the
available permission modes on every model switch and downgrades the current
one to `default` when the new model does not support it, so a turn that asked
for plan/read-only and also switched models ran with WIDER permissions than
it requested. Model and ordinary options now go first and permission-bearing
ones last, so the last word belongs to what was asked for. `applyPromptConfig`
already runs before `prompt`, so the state read afterwards is still taken
before the agent can act on it.

That was invisible because a successful `session/set_mode` overwrote the
agent's reported mode with the requested one in the runtime patch — the
request echoed back as if it were the outcome. It now only FILLS a mode the
agent's own state does not report, mirroring what the model branch already
did. Without this, no mode divergence could ever be reported.

The regression test fails when the ordering is reverted; the existing fixture
that hid it now reports the mode it accepted, like a real agent.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… omits

Two ways a guess became a promise, and a promise became invisible.

`applyAgentRoleRunConfigDefaults` seeded a new Role from `provisional`
capabilities — the built-in static tables, a hand-maintained copy of a catalog
the agent fetches per account. A Role is a durable promise about how a Session
runs, so seeding it from a guess persisted that guess as the commitment. It is
how a Role came to promise Fast mode for an agent whose probe never published
the option. Only `authoritative` capabilities seed now; the rest stay unset for
the user to choose.

The composer then deleted what it could not find. Its authoritative branch
walked the selector catalog and dropped every key outside it — but that catalog
comes from a snapshot of ONE model, so a Role pinning Fast on a fast-capable
model had its own value removed from the composer meant to show what the Role
will do, and removed again from the dispatch table. A stored key with no
selector is now kept while no runtime table exists, and `filterAcpSessionConfigOptionValues`
drops only values a selector it HAS rejects. A present runtime table still owns
the whole key set: that one is the agent's live state, not a snapshot of some
other model, so an omission there is an answer.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s last

Rewrites the per-model invariant around what the change actually established:
a capability snapshot is evidence about one model and never rejects a run
config, the only loud failure left is a missing wire binding, and Roles are
seeded from authoritative capabilities alone. Adds the ordering invariant —
permission-bearing config last, a set_mode acknowledgement is not the state,
divergence is reported rather than blocked, and an upgrade must never turn a
Role that used to run into one that fails.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f9a5cd4a1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/shared/src/acp-run-config.ts Outdated
validatedConfigIds.push(configId);
}
configOptionValues[configId] = selection.reasoningEffort;
configOptionValues[option?.id ?? ACP_REASONING_EFFORT_CONFIG_ID] = selection.reasoningEffort;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use Claude's effort binding when the snapshot omits it

When a Claude capability snapshot was captured under a model that omits the thought-level option, option is undefined and this falls back to the Codex-specific reasoning_effort id, even though this module identifies Claude's id as effort. After switching to the requested model, the applier therefore sends an unknown option and the session runs with the agent's default effort instead of the requested value. Select the fallback binding by agentType, as is already done for fast mode, or fail for a missing binding.

Useful? React with 👍 / 👎.

Comment on lines +341 to +344
for (const [configId, value] of Object.entries(values ?? {})) {
if (!cataloged.has(configId)) {
filtered[configId] = value;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drop config keys absent from the current selector schema

When cached preferences contain an option from a previous agent, model, or capability revision, this loop now copies that uncataloged value into the supposedly filtered result. The landing and child-session creation paths call this helper immediately before dispatch and persist the result as defaults, so stale settings continue to be sent and stored rather than being removed at the dispatch boundary; preserve only explicitly recognized per-model exceptions instead of every unknown key.

AGENTS.md reference: packages/components/src/lib/AGENTS.md:L52-L56

Useful? React with 👍 / 👎.

Three review findings, all in the same seam.

The fallback wire id for reasoning effort was Codex's `reasoning_effort` for
every agent. Claude spells it `effort`, so a snapshot captured without the
option — exactly the case the fallback exists for — sent Claude an id it has
never heard of, and the turn ran at the default effort. Both fast mode and
effort now resolve through one per-agent binding table, and an agent with no
known binding reports that the control cannot be encoded rather than borrowing
another agent's spelling.

Preserving stored config values outside the selector catalog was too broad: a
removed or renamed option has no surface left to clear it, so every new Session
would resend and re-warn about it forever. Only ids Lody knows name a per-model
control keep that exemption — the case it was for, a Role pinning Fast on a
fast-capable model the probe never ran. Anything else uncataloged is stale and
goes.

The Role form still drew run-config controls from `provisional` capabilities
while seeding refused to fill them, so a boolean rendered Off and saved as
unset: the Role promised a configuration nobody chose. Those controls now
appear only for capabilities the agent itself reported, with the existing
"open the agent once, then edit this role" message otherwise.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d9217976c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// it only FILLS a mode the agent's own state does not report — never
// overwrites one, which would report the request back as if it were the
// outcome and leave every mode divergence invisible.
if (confirmedLegacyModeId && runtimeConfigPatch.modeId === undefined) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for fresh mode state before reporting divergence

For built-in Codex sessions, AgentClient.setSessionMode uses legacy session/set_mode and does not update its cached configOptions from the response. If the adapter does not synchronously emit a config_option_update, runtimeConfigPatch.modeId here is therefore the pre-call mode rather than a post-apply result; changing modes then records a false agent_warning and persists the old mode even though the request succeeded. Treat the cached value as unknown unless a post-call state update was observed.

AGENTS.md reference: apps/cli/AGENTS.md:L306-L309

Useful? React with 👍 / 👎.

zxch3n and others added 2 commits September 3, 2026 17:45
…requested

The single exception to "report, never block". Everything else — model,
effort, fast — still runs and warns, because the worst case is a slower or
costlier turn. Running with MORE permission than was asked for is not that: by
the time a notice is readable the agent may already have edited files, and a
session sitting in `auto` that refuses a `plan` request keeps approving
without a human.

It fires only on a contradiction the agent itself establishes.
`runtimeConfigPatch.modeId` is the agent's published state — it is filled from
a `set_mode` acknowledgement only when the agent reports no mode of its own,
in which case the two are equal and nothing fires. `isAcpPermissionWiderThanRequested`
then requires BOTH modes to be ranked among the builtin ones and the effective
one to be strictly wider. A snapshot, a stale cache, an unranked third-party
mode, an unconfirmed request, and a NARROWER outcome all answer false. The
ranks come from the semantics the repo already classifies (read-only/plan <
ask < model-reviewed < accept-edits < skip-approval), not from mode names.

The way out is explicit and scoped to one turn:
`SessionTurnInputConfig.acceptWiderPermission` is informed acceptance carried
by a single resend — never inherited, never a default — and it stands the stop
down while still reporting the mismatch. The failure keeps its own reason
(`permission_not_applied`) so the client can name both modes and offer that
resend instead of showing a generic pre-prompt error.

Ablation: removing the detection fails the escalation test; ignoring the
acceptance flag fails the stand-down test.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The permission_not_applied notice told the user to resend to run with the
agent Permission it has, but a plain resend carries no acceptWiderPermission
flag and fails again. State what happened; the affordance that sets the flag
is a follow-up.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f0f61a322

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +337 to +341
const permissionEscalation =
requestedModeId !== undefined &&
!config.acceptWiderPermission &&
isAcpPermissionWiderThanRequested(requestedModeId, runtimeConfigPatch.modeId)
? { requestedModeId, effectiveModeId: runtimeConfigPatch.modeId as string }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat Codex mode state as unknown until refreshed

When built-in Codex changes modes, AgentClient.setSessionMode uses the legacy session/set_mode path without updating its cached configOptions, so moving from agent-auto-review or agent-full-access to read-only can leave runtimeConfigPatch.modeId holding the pre-call wider mode. This comparison then reports a permission escalation and MessageHandler aborts before prompting even though the mode request succeeded. Fresh evidence beyond the earlier comment is that the added permissionEscalation path now upgrades the stale-state warning into a failed turn; compare only state observed after the request or treat the cached mode as unknown.

AGENTS.md reference: apps/cli/AGENTS.md:L322-L324

Useful? React with 👍 / 👎.

Comment on lines +332 to +336
const requestedModeId =
config.modeId ??
(typeof configOptionEntryFor(modeConfigId) === 'string'
? (configOptionEntryFor(modeConfigId) as string)
: undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check agent-owned permission selectors for widening

For Grok and other agents whose approval setting is an agent-owned _permission option such as configOptionValues.permission_mode, this derives the requested permission only from the legacy mode selector. If ask is rejected or dropped while the live value remains always-approve, the code records a warning but never produces permissionEscalation, so the prompt executes with automatic approval. Include agent-owned permission-option values in the requested/effective widening comparison.

AGENTS.md reference: AGENTS.md:L67-L73

Useful? React with 👍 / 👎.

* another. Blocking a turn on a guess about an unknown mode would be the same
* mistake as blocking it on a stale snapshot.
*/
const ACP_PERMISSION_MODE_RANKS: Record<string, number> = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rank DeepSeek's workspace-write permission mode

The rank table omits the built-in DeepSeek workspace-write mode even though packages/shared/src/ai.ts uses it as DeepSeek's default permission mode. If a turn requests read-only but the agent reports workspace-write, findAcpPermissionModeRank returns undefined for the effective side and the widening guard allows the prompt to run with write access. Add every adapted built-in permission mode, including workspace-write, at its correct width.

AGENTS.md reference: apps/cli/AGENTS.md:L319-L324

Useful? React with 👍 / 👎.

… agent has

The stop that PR #333 added had no way out from the UI: the notice said the
turn was stopped and a plain resend would be stopped again, because it carries
no acceptance. The failure notice now offers the decision the stop exists to
ask for.

The action replays THAT turn — the prompt, mode, model, config option values
and Role frozen in its own `inputConfig`, never the composer's current
selection, which would pair the old prompt with settings the user has since
changed. `acceptWiderPermission` rides only this dispatch: it is written into
the new turn's input config and into nothing that outlives it, so the next
ordinary send asks for the original permission again. The mismatch is still
reported on the turn that runs.

Both permissions are named, in the notice and on the button. "Run anyway"
would hide which one the turn is about to run with, and that is the only thing
the user is being asked to decide.

`useOneShotAction` closes the double-click window before the first await —
`pending` drives the disabled attribute, but React has not re-rendered when
the second click lands — and always clears, so a failed attempt leaves the
action usable rather than dead on the one turn being recovered. A newer user
message supersedes the offer entirely: replaying then would inject the old
turn behind whatever was just sent.

Ablation: removing the in-flight guard fails the double-click test, removing
the supersede rule fails the newer-message test, and defaulting the flag on
fails the ordinary-send test.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7475c1aeac

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/shared/src/ai.ts Outdated
* wider permission than requested and chose to run anyway. Scoped to the turn
* that carries it — never inherited, never a default.
*/
acceptWiderPermission?: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear one-time permission acceptance from edited turns

When a successfully accepted retry is later edited and resent, session-chat-interface.tsx builds the replacement with { ...originalConfig }, and SessionEditAndResendService.buildReplacementInputConfig preserves it again. Consequently this flag remains true for the newly edited prompt, bypassing the wider-permission stop without a new informed acceptance even though this field is documented as scoped to one turn. Strip it from replacement configurations unless the current action explicitly supplies it.

Useful? React with 👍 / 👎.

Comment on lines +4025 to +4028
const accepted = await dispatchInputBlocks(target.inputBlocks, {
modeIdOverride: target.modeId ?? null,
modelIdOverride: target.modelId ?? null,
configOptionValuesOverride: target.configOptionValues ?? {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the failed turn's MCP selection on retry

If the user changes the composer’s MCP selection after this failure notice appears, the retry passes the frozen prompt and run config but not the failed turn’s mcpServerIds; enqueueInputBlocks therefore reads mcpSelection.selectedIds from the current composer. The old prompt can consequently rerun with newly exposed tools or without tools it originally selected. Include the failed input config’s explicit MCP selection in the retry target and override rather than rereading composer state.

AGENTS.md reference: AGENTS.md:L52-L55

Useful? React with 👍 / 👎.

: target.agentRoleId === null
? null
: undefined,
acceptWiderPermission: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Carry retry acceptance through durable dispatch

When the machine is offline, this action still durably writes the retry turn, but SessionDispatchWatcher.buildChatRequestFromHistoryEntry and buildCreateRequestFromHistoryEntry reconstruct acpSessionConfig without entry.inputConfig.acceptWiderPermission. After reconnect the daemon therefore sees no acceptance and stops the same turn again, leaving the advertised recovery action ineffective for the supported offline-send path. Copy the one-turn flag into both reconstructed requests.

AGENTS.md reference: packages/components/src/components/sessions/AGENTS.md:L762-L764

Useful? React with 👍 / 👎.

const permissionEscalation =
requestedModeId !== undefined &&
!config.acceptWiderPermission &&
isAcpPermissionWiderThanRequested(requestedModeId, runtimeConfigPatch.modeId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not classify Claude dontAsk as wider

Even with fresh post-apply state, a Claude session that remains in dontAsk after requesting default or auto is stopped here because ACP_PERMISSION_MODE_RANKS assigns dontAsk rank 4. The built-in mode definition says dontAsk denies operations that are not pre-approved, so it is not wider than modes that can ask a human or reviewer to approve them. Rank it as non-widening so this guard does not turn that narrower outcome into a permission escalation.

AGENTS.md reference: apps/cli/AGENTS.md:L320-L324

Useful? React with 👍 / 👎.

Four review findings, all cases where the stop or the replay was narrower
than the thing it claims to protect.

The live permission check only looked at the MODE. Grok's real permission
control is a `category: '_permission'` config option (`permission_mode`,
`ask`/`auto`/`always-approve`), so a turn asking for `ask` that the agent
reported as `always-approve` produced a warning and ran — precisely the
escalation the stop exists for. Permission is now recognised in all three
shapes it arrives in — the legacy `set_mode` selector, a `mode` config option,
and an explicit `_permission` one — each requested value is compared against
the agent's reported value for THAT control, and all of them are applied after
the model so a model switch cannot overwrite them. The rank table gains the
values it was missing: Grok's `ask`, and DeepSeek Harness's `workspace-write`,
without which `read-only → workspace-write` was silent too.

"Run once with the agent's permission" replayed the stopped turn's prompt and
run config but let the composer supply `mcpServerIds`, `taskToolsEnabled` and
`issuePRMentions` — so changing an unsent MCP selection and then accepting
would pair the old prompt with tool reach that turn never had, and an explicit
`mcpServerIds: []` was lost entirely. All of them now come from the frozen
`inputConfig`; `acceptWiderPermission` remains the only thing the new turn
adds.

Kimi publishes reasoning as `thinking`, which the per-agent binding table did
not know: a snapshot-less encode reported "cannot be encoded" and a saved Kimi
Role value was filtered out as an unknown key.

Hiding the Role form's run-config controls for unreported capabilities stopped
a guess being seeded, but left the Role saveable with nothing pinned — a Role
IS its run config and pins the permission mode. `validateAgentRoleForm` now
refuses that combination; an existing Role that already carries values stays
editable while its agent is unreachable.

Ablation: each fix has a test that fails when it is reverted — `_permission`
recognition, the two rank additions, the Kimi binding, the frozen tool reach,
and the Role save gate.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e507a471f2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

: target.agentRoleId === null
? null
: undefined,
acceptWiderPermission: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forward retry acceptance through dispatchInputBlocks

In the normal idle/online retry, this call enters the direct branch, but dispatchInputBlocks reconstructs the arguments to directDispatchInputBlocks with only the mode, model, config values, and Role; it drops acceptWiderPermission and all three frozen tool overrides. The button therefore creates another unaccepted turn that is stopped by the same permission guard, while its MCP/task/mention reach falls back to the current composer. Fresh evidence beyond the prior offline-reconstruction comment is that the values are lost before either history persistence or RPC dispatch, so the retry is broken even online; forward these fields through the direct and queue branches.

AGENTS.md reference: AGENTS.md:L52-L55

Useful? React with 👍 / 👎.

zxch3n and others added 2 commits September 3, 2026 18:59
Two review findings, both places where a narrower path defeated the guard
above it.

The permission retry handed `acceptWiderPermission` and the frozen tool config
to `dispatchInputBlocks`, which forwarded only mode/model/config/Role to the
direct and queue hops. The offer is shown while the agent is idle, so the
direct hop is the one it takes: the new turn reached the daemon with no
acceptance and was stopped again, while its MCP, task-tool and issue-mention
values came from the composer rather than from the turn being replayed.

They now travel as one `TurnScopedOverrides` carrier that the inner send takes
as a REQUIRED field, so a route that forgets it fails to compile rather than
silently producing a turn the daemon will stop. Every route — direct, queue,
guide — forwards it, `applyTurnScopedOverrides` is the single last hop before
`buildSessionTurnInputConfig`, the queued config reads the built config instead
of re-reading the composer, and the button asks for `forceDirect` so the
decision the user just made is not parked behind a queue.

The Role create gate keyed on the run config being empty, which the composer
defeats: chat landing and the input area create a Role from what they
currently show, and under `provisional` capabilities that is the static
tables' own defaults resolved into a non-empty value nobody chose. Emptiness
cannot tell that from a real selection, so the rule is now the creation
itself — `isEditingExistingRole` comes from `editor.mode`, never inferred from
the value. Editing an existing Role stays open offline.

Ablation: neutering the carrier at a hop, dropping the frozen MCP selection at
the last hop, and reverting the gate to the emptiness check each fail their
test.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uild

The flag was in the schemas but not in `normalizeSessionTurnInputConfig`,
which rebuilds the turn config field by field — and every transport runs the
config through it: direct RPC, `session/dispatch-turn`, steer, the Loro
history readback, queue promotion. So the acceptance the client set was
dropped before the daemon could read it, and the accepted turn was stopped
again. Two more hand-written rebuilds had the same hole: the dispatch
watcher's `acpSessionConfig` constructions (the doc-driven path, which is the
one the desktop actually takes) and queue promotion's own
`buildSessionTurnInputConfig` call.

All four now copy ONLY an explicit `true`. `false` and absent are the same
answer — not accepted — and neither may be written back as something a later
turn could read as an acceptance. The queued config's strict schema admits the
field too, or a queued replay would be rejected whole.

`buildCliHistoryInputConfig` deliberately does not copy it: CLI and MCP turns
never carry an acceptance, and it must not become inheritable there.

Ablation: removing the normalizer branch fails the round trip; copying the
value verbatim instead of gating on `true` fails it too.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09d9fc9070

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +349 to +350
if (config.acceptWiderPermission) {
return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind permission acceptance to the mode the user approved

When a stopped turn is replayed after reconnect or another agent-state change, this boolean bypasses the comparison before reading the newly reported permission. A user who accepted “Run once with auto” can therefore have the retry run with always-approve or full access if the effective state changes; freezing the requested config does not freeze the agent's outcome. Carry the approved effective mode (or its rank ceiling) and suppress the stop only when the newly reported mode is no wider than what was disclosed.

AGENTS.md reference: apps/cli/AGENTS.md:L322-L326

Useful? React with 👍 / 👎.

Making the acceptance survive every rebuild also made it copyable, and
edit-and-resend copies: it normalizes the original turn's config, spreads it,
and mints a new `userTurnId` with a new prompt. The consent the user gave for
ONE prompt would have ridden along to a different one, and that turn would
sail past the stop that exists to ask — a permission bypass assembled out of a
spread.

`deriveTurnInputConfigForNewTurn` is now the only way to build a turn config
from another turn's, and it drops the acceptance. Edit-and-resend and the
history replay import (which also rewrites the prompt) go through it.

Same-turn rewrites deliberately do not: marking a turn `processing`, tagging
`_lodyDeliveryKind`, or retrying its transport is still the turn the user
accepted, so those keep it. `use-task-comment-dispatch` builds a fresh config
rather than deriving one, so it never carried it in the first place.

Ablation: making the derivation spread the config unchanged fails the new
test; removing the normalizer branch fails both it and the round trip, so the
two halves cannot drift apart unnoticed.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e6c99b7dd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +52 to +54
...(target.mcpServerIds !== undefined
? { mcpServerIdsOverride: target.mcpServerIds as McpServerId[] }
: {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat an absent retry MCP selection as empty

When the stopped turn lacks mcpServerIds—notably for CLI-created turns, because buildCliHistoryInputConfig never records it—this conditional emits no override, so applyTurnScopedOverrides supplies the current composer's MCP selection even though the dispatch watcher originally interpreted the absent field as []. Retrying can therefore expose the old prompt to MCP servers that were unavailable to the original turn. Fresh evidence beyond the earlier retry comment is the newly added turn-scoped-overrides test that explicitly expects this composer fallback for an absent selection; freeze absence as an empty selection instead.

AGENTS.md reference: AGENTS.md:L52-L55

Useful? React with 👍 / 👎.

`acceptWiderPermission: true` suppressed EVERY escalation on the turn that
carried it, while the button that sets it only ever discloses one — the pair
from the last failure notice. Two ways that hands out consent nobody gave:
the agent may have moved further by the time the turn re-runs (`plan → auto`
accepted, `plan → always-approve` live), and a second permission control may
have widened alongside the one in the notice, which Grok can do because it
carries both a mode selector and an explicit `_permission` one.

The acceptance is now the triple it was always meant to be —
`{ controlId, requestedModeId, effectiveModeId }` — written from the notice's
own meta, so the daemon can tell the disclosed difference from one that
appeared afterwards. The applier skips only an exact match and CONTINUES
scanning the remaining permission selections; anything else stops the turn
again and produces its own accurate notice. Matching is on the whole triple,
never on the values alone, so consent cannot transfer between permission
dimensions that happen to share them. A malformed or partial acceptance reads
as none.

Ablation, all three now caught: suppressing everything when accepted, ending
the scan at the accepted item instead of continuing, and dropping `controlId`
from the match.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 92e039ad27

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/shared/src/message-schemas.ts Outdated
* the agent reported. Structured so the notice can name both instead of the
* client parsing them back out of `message`.
*/
permission: z.object({ requestedModeId: z.string(), effectiveModeId: z.string() }).optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the permission control in failure metadata

When a permission_not_applied notice is read through the exported parseMessageContentArray or safeParseMessageContentArray path, this nested Zod object strips the undeclared controlId. findPermissionNotAppliedRetryTarget requires all three permission fields and therefore returns null, so the “Run once” recovery action disappears for parsed history and the stopped turn cannot be resumed from the UI. Reuse AcceptedWiderPermissionSchema here so the durable notice retains the complete acceptance tuple.

Useful? React with 👍 / 👎.

…trol id

`ChatFailedMetaSchema` still declared its own two-field `permission` shape
while everything else had moved to the triple, and Zod strips undeclared keys:
`controlId` was removed on every history read. The client requires all three
to build an acceptance, so a user saw the failure but no "run once" action —
the way out of a stop the daemon had reported correctly was unreachable
through the real read path.

The meta and the acceptance are the same thing — the client reads one and
writes the other back — so they now share `AcceptedWiderPermissionSchema`
rather than keeping a second shape that can drift.

A malformed or pre-triple `permission` degrades to absent instead of failing:
rejecting it would make the whole history item unparseable rather than merely
actionless, and an acceptance that cannot name its control would be a blanket
one, so dropping it is the safe direction.

Ablation: the old two-field shape fails both new tests; making the triple
strict without the degrade fails the legacy-notice one.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a82d970f5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if (
typeof selection.requested !== 'string' ||
typeof effective !== 'string' ||
!isAcpPermissionWiderThanRequested(selection.requested, effective)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rank Grok interaction modes per control

When builtin Grok is asked for interaction_mode=ask but reports agent, this value-only comparison treats both modes as rank 1 and allows the prompt to run. However, Grok defines ask as answering without modifying the workspace and agent as permitting tools and file changes (packages/components/src/lib/grok-acp-selector-i18n.ts:12-29), so this is a real widening that bypasses AcpPermissionNotAppliedError; rank values in the context of controlId rather than sharing one global mapping across interaction and approval controls.

AGENTS.md reference: apps/cli/AGENTS.md:L327-L340

Useful? React with 👍 / 👎.

Matching each acceptance exactly stopped the bypass but made the way out
unreachable when two controls widen at once, which real Grok does: it carries
both a mode selector and an explicit `_permission` one. The first stop
disclosed one, the retry carried that acceptance alone, the applier correctly
stopped on the second — and the next retry replaced the first acceptance with
the second, landing back on the first stop. The user alternates between two
notices forever.

`acceptWiderPermissions` is now the list it needed to be: strictly validated,
deduplicated and bounded. A retry inherits the acceptances already on the turn
it replays — re-validated from that turn's own frozen config, so nothing but a
previously accepted exact triple accumulates — and appends the current notice's
triple. This grants nothing extra: the applier still matches every entry
exactly and keeps scanning, so a difference nobody was shown stops the turn as
before.

The clearing rules are unchanged and now cover the whole set: an ordinary send,
edit-and-resend and any prompt-derived turn carry none of it; a same-turn
transport retry keeps it. A legacy boolean or a bare object reads as no
acceptance rather than a partial one.

Ablation: a retry that keeps only the current disclosure fails the
accumulation test; an applier that matches only the first accepted entry fails
the full loop.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74e063a885

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +2961 to +2964
// A new turn id and a changed prompt: the one-time permission acceptance
// the user gave the ORIGINAL prompt does not travel with it.
const inputConfig: SessionTurnInputConfig = deriveTurnInputConfigForNewTurn(
message.inputConfig,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear permission acceptances in the edit service

When an accepted retry is edited, this helper omits acceptWiderPermissions, but SessionEditAndResendService.buildReplacementInputConfig subsequently spreads original.inputConfig before the replacement; because omission does not overwrite the original property, the old acceptance survives onto the changed prompt and bypasses the permission stop. Fresh evidence beyond the earlier comment is that the current UI-side fix is undone by the still-active service merge at apps/cli/src/session/session-edit-and-resend-service.ts:487-493; explicitly remove the field at that final replacement boundary.

Useful? React with 👍 / 👎.

Comment on lines +158 to +160
options.capabilityAuthority !== undefined &&
options.capabilityAuthority !== 'authoritative' &&
!options.isEditingExistingRole

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block offline edits that retarget an existing Role

When editing an existing Role while the newly selected agent has provisional or unavailable capabilities, isEditingExistingRole bypasses this validation even if the user changed the machine or agent config. Selecting that config clears the mode, model, and option values, while the capability controls remain hidden, so Save persists a retargeted Role with an empty or unverified run config rather than merely allowing an offline rename. Limit the exemption to edits that preserve the original target and run config, or prevent retargeting until authoritative capabilities are available.

AGENTS.md reference: AGENTS.md:L67-L73

Useful? React with 👍 / 👎.

zxch3n and others added 2 commits September 3, 2026 20:05
… knew

Inheritance kept every uncataloged key, so a removed or renamed option rode the
Session lineage forever: a new MCP `session_create` inherits it from the
opener's last turn, dispatches it, the agent rejects or warns, and that config
becomes the next Session's inheritance source — with no surface anywhere to
clear it. The components side was narrowed to exactly this rule in 6d92179; the
CLI kept the hole, and a test even pinned it.

Inheritance is not a request. Nobody asked for these values on this turn, so an
id the catalog does not know gets no benefit of the doubt —
`filterInheritedTurnConfigOptionValues` keeps it only when
`isAcpPerModelConfigId` recognises it, because those are absent from a snapshot
whenever the captured model lacked them, which says nothing about the model a
new Session runs. A cataloged option whose value the type cannot carry is still
dropped.

Explicit requests are untouched: `--config-option` and a frozen Operation
request are asked for, and a snapshot of one model still does not get to refuse
them. A test now pins that half too, so the split cannot quietly collapse in
either direction.

With no capability at all the same rule applies. An explicit request is always
available, so a missing snapshot must not become a licence to carry every
historical key forward — that is how the accumulation starts.

Ablation: keeping every uncataloged key fails the new test, dropping the
per-model ones fails it too, and rejecting unknown ids on the explicit path
fails three.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ablation on this branch's own additions: removing the wiring that merges
`findUnverifiedTurnSelectors` into `applyAgentRunConfigSelection` broke no
test, because the value it produces is read nowhere. It was computed at three
levels — the shared resolver, the CLI merge, the returned object — and
consumed at zero. That is the same dead apparatus this branch set out to fix
in the first place, rebuilt.

The runtime divergence comparison is the report. An offline classification of
what "could not be confirmed" only made sense while the snapshot could reject;
with rejection gone it tells nobody anything. Deleted end to end, along with
the effort/fast confirmation bookkeeping that existed solely to feed it, the
now-unused selector-id helper, and the tests that pinned the value rather than
any behaviour. `modelReasoningEfforts` keeps its real consumer — the MCP
create-options summary.

Two dedupe implementations of the accepted-permission triple became one,
exported from shared: the client assembling a retry and the schema validating
it on the way in now agree by construction, and the schema's bound is counted
in the same entries.

Ablation also found `.max(8)` and the schema dedupe passing silently — no test
noticed their removal. Both are security-relevant (an unbounded durable list
from a client; a bound that duplicates could exhaust), so they earned coverage
rather than deletion: a 9-entry list now reads as no acceptance, and a repeated
disclosure does not consume the budget.

Also dropped `EMPTY_TURN_SCOPED_OVERRIDES` (no consumers) and unexported
`findAcpPermissionModeRank` (used only inside its own module).

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 768456b148

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

record.acceptWiderPermissions
);
if (acceptWiderPermissions?.length) {
normalized.acceptWiderPermissions = acceptWiderPermissions;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Strip one-turn acceptance from operation continuations

When a permission-accepted turn invokes an asynchronous MCP session operation, preserving this field lets resolveInvokingTurnContext copy it into frozenContinuationConfig (apps/cli/src/mcp/lody-mcp-server.ts:2200-2206), after which writeCompletionTurn spreads it into a new system turn while replacing the prompt and turn id (apps/cli/src/orchestration/operation-coordinator.ts:1013-1017). The durable completion and any nested operation snapshots therefore carry acceptWiderPermissions for a prompt the user never approved; sanitize those operation-derived configs with deriveTurnInputConfigForNewTurn before freezing or writing them.

AGENTS.md reference: apps/cli/AGENTS.md:L402-L408

Useful? React with 👍 / 👎.

Targeted review of the acceptance chain found four places where it leaks
across turns. The client-side rule was right; the enforcement was in the wrong
place.

Edit-and-resend rebuilds the config in the DAEMON as
`{...original.inputConfig, ...replacement}`. The client drops the acceptance,
but a replacement that merely lacks the field does not overwrite a present one,
so the CLI added it straight back: accept for prompt A, edit to prompt B, and B
runs past the stop nobody accepted it for. The rebuild now goes through
`deriveTurnInputConfigForNewTurn`, so a correct client is no longer what makes
this safe. Editing a queued item and the Operation completion turn are the same
derivation and now drop it too — the latter never dispatched it, but a nested
Operation would freeze and copy that history forward.

The notice also never said which turn it belonged to; the client took the
nearest user entry above it. Another client's turn, or an edit-and-resend
rewriting history between the failure and the notice landing, leaves a
different prompt there — and if it makes the same permission selection, the
acceptance runs it. The notice now carries `userTurnId` and the client matches
by id, with adjacency kept only for notices written before the field.

`deriveTurnInputConfigForNewTurn`'s overrides can no longer re-add the field,
and the speculative-preparation schema no longer accepts it: a preparation is
not a dispatched turn, so no difference was ever disclosed for it.

Reviewed and found clean: `controlId` agrees between the notice and the applier
for both the legacy `set_mode` selector and a `category:'mode'` option, and the
rank table covers every builtin permission value at the pinned adapter SHAs.

Ablation: reverting either P0 fails its regression test.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7058c9e412

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1754 to +1755
if (permissionEscalation) {
throw new AcpPermissionNotAppliedError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop the active prompt before rejecting a steer

When Claude steering advertises configPolicy: 'apply', steerSessionLocked calls this code while the preceding prompt is still in flight (session-execution-service.ts:1245-1249). If the steer switches models, Claude can reset plan to the wider default; when restoring plan then diverges, this throw only causes the steer to be requeued by the catch at lines 1344-1355, without cancelling the already-running prompt. That prompt can therefore continue making tool calls under the widened mode before any failure notice appears, so the active run must be yielded/cancelled before applying the steer configuration or the configuration must not mutate the live session until handoff.

AGENTS.md reference: apps/cli/AGENTS.md:L334-L338

Useful? React with 👍 / 👎.

@zxch3n
zxch3n changed the base branch from feat/acp-run-config-divergence-warning to main September 4, 2026 10:04
zxch3n and others added 2 commits September 4, 2026 18:34
A capability snapshot describes ONE model — the one that was current when
`session/new` answered. Codex only lists `fast-mode` while that model has a
fast speed tier, and both agents rebuild the effort list per model, so nothing
in the snapshot answers "can Luna run Fast?" and ACP has no request that asks.
Every surface was therefore reading a per-model fact off a model-scoped
snapshot and getting the wrong answer for every other model.

So the adapters now attach `_meta.lody.modelCapabilities` to `session/new`:
`{ version: 1, models: { <id>: { effortValues?, fastMode? } } }`, built from
the same `Model`/`ModelInfo` objects the config options already come from. The
CLI records it beside the snapshot along with `measuredForModelId`, the model
the snapshot is actually about.

It is advisory in one direction: it may describe a control the snapshot never
carried, and it never grants permission, authorizes a value, or rejects one. A
model it does not name is unknown, not unsupported — so the catalog is read
whole or ignored whole, since half a catalog would answer "no fast mode" for
models the agent could not fit under the bound. Freshness-gated by a TTL and by
`sourceVersion`, because a declaration heard from one adapter build says nothing
about the next.

Two storage rules the tests pin, both easy to undo by accident: an ordinary
refresh that heard no declaration must not clear the stored one, and the
write-dedup key must include the declared content or the first declaration to
arrive with an otherwise unchanged snapshot is silently dropped.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion

The producer half of `_meta.lody.modelCapabilities` lives in the adapter
repositories, so the consumer landed first and read an absent declaration as
"nothing to say". These two pointers are what actually make it speak.

Both are on `feat/declare-model-capabilities` in their own repositories; the
pointers move again when those merge.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca54a38939

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +3128 to +3130
// Content, not freshness: `receivedAt` is deliberately excluded so a
// re-probe that learns nothing new does not rewrite the row, while a
// catalog that actually changed does.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh identical declarations before their TTL expires

When later probes return the same _meta.lody.modelCapabilities, they supply a fresh receivedAt, but omitting that field from this fingerprint makes the equality check at lines 3280-3284 return without persisting it. After 24 hours, readDeclaredModelCapabilities therefore permanently treats the declaration as stale until its content changes, causing model-specific Fast and effort capabilities to disappear even though the agent has repeatedly reconfirmed them; refresh the timestamp whenever an explicit declaration is received while retaining the no-write behavior for probes that omit it.

AGENTS.md reference: apps/cli/AGENTS.md:L304-L310

Useful? React with 👍 / 👎.

The declaration commit had been written on top of `fix: hide unknown context
window usage`, which is what the submodule happened to be checked out at and is
not on the adapter's `main`. Rebased onto `main` so the PR carries one
independent commit, and the pointer follows.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant